quonfig 1.5.0 → 1.6.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: 5be29a29cae00eb43e09f2aadef25191ec4dc9959ff0037cdfd7ba89dfbd5a10
4
- data.tar.gz: b47703c3beb5a4f85f6b2237b3e4295e8e82055ab105168827e750dbd428d13e
3
+ metadata.gz: 96d17a74751571b9c6099b8239b2ccaafb25a5f7e55d006cecae5cb1f8651979
4
+ data.tar.gz: 6bbe54bd07a5fb1e20b987ef8a8aaef417533ca8ef26000873db8a671142d4bb
5
5
  SHA512:
6
- metadata.gz: 7616048fcd0a2a0727ac79a0a801f35d71be44cd54667dc4aaa8aa86009033194f20abbfb69f0b959dc5ba90cdf3e29069db1384e8a432caafe34898f69e3165
7
- data.tar.gz: 31d412725f88752b8cbde55daa05c99b926212c2951519a177c24dae72b19d665b3452f4f723e5b2b7e78258cdb37e064733e18249316f7b055846408d2eda92
6
+ metadata.gz: dcb86b3f98c5f986d61f0b0db084b992fe4c3bfefc86ef2f7d0f65487fa95dc6d66fdd4b5150dd2c0f8b6ddd819f6c0bc77980e4ae89d618d31daa3649bc48c6
7
+ data.tar.gz: 9faba58f93536256b216dd70dc95849adbb1c4e18b033feb9419d87d26dd8b04ec132738f4bfcbee3b0e7dca0b9051bad43971644b3312d0320ee496e8550efc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.0 - 2026-09-28
4
+
5
+ - **Change: a weighted rollout that hashes on a property missing from the context now hashes an empty value, so every such caller gets the same variant for that flag (qfg-9dxb.8).** Before, such evaluations got a random variant on every call. It is the same variant a context with the property set to `""` gets, and a variant with weight 0 is not served. The reason is still `SPLIT`. The `flag_metadata` of `get_*_details` results includes `'hashPropertyMissing' => true` when the property is missing, and the SDK logs one warning per flag. A weighted rollout with no hash property configured still picks a random variant on every evaluation, unchanged. Users whose context has the property keep the same variant as in 1.5.0.
6
+ - **Change: once the client holds a numbered config generation, a payload with generation 0 is ignored and the client keeps its current config (qfg-9dxb.9).** Only a delivery server with a damaged git store sends one. In 1.5.0 such a payload installed and could move the client back to older config. `held_generation` never goes backward. A client that has never received a real generation (for example one talking to `qfg serve`) still installs every payload.
7
+ - **Fix: a segment or `decryptWith` key that references itself (directly or through a chain) no longer crashes with `SystemStackError` (qfg-9dxb.7).** The looping segment reference evaluates as a missing segment. A `decryptWith` loop raises `DecryptionError`.
8
+ - **Fix: an HTTP 200 or SSE event that is not a config payload (for example `{}` from a proxy) is ignored instead of deleting all configs (qfg-9dxb.3).** Over HTTP the SDK tries the next server.
9
+
3
10
  ## 1.5.0 - 2026-09-25
4
11
 
5
12
  - **Telemetry transport policy (qfg-y8je.8).** The telemetry POST had no timeout of its own (Faraday's defaults, 60s connect + 60s read); it now has a 15s overall deadline (`telemetry_timeout_ms`) and a 5s connect + TLS deadline (`telemetry_connect_timeout_ms`). A failed batch is kept byte-for-byte and resent (never merged with newer data, so the server dedups a resend of a batch that did land). Resends happen no sooner than 30s after a failure and honor `Retry-After` up to 10 min. The retained queue is capped at 5 batches / 2MB / 5 min (oldest dropped). At most one POST is in flight. 401/403/404 disable telemetry for the process with one ERROR; any other 4xx drops that batch with one ERROR. Before this, a failed batch was simply lost.
@@ -1636,13 +1636,15 @@ module Quonfig
1636
1636
  end
1637
1637
 
1638
1638
  reason = result.of_reason
1639
+ flag_metadata = build_flag_metadata(
1640
+ config_id, config_type, result.rule_index, result.weighted_value_index, reason
1641
+ )
1642
+ flag_metadata['hashPropertyMissing'] = true if result.hash_property_missing
1639
1643
  Quonfig::EvaluationDetails.new(
1640
1644
  value: coerced,
1641
1645
  reason: reason,
1642
1646
  variant: build_variant(reason, result.rule_index, result.weighted_value_index),
1643
- flag_metadata: build_flag_metadata(
1644
- config_id, config_type, result.rule_index, result.weighted_value_index, reason
1645
- )
1647
+ flag_metadata: flag_metadata
1646
1648
  )
1647
1649
  rescue StandardError => e
1648
1650
  Quonfig::EvaluationDetails.new(
@@ -1,5 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- ConfigEnvelope = Struct.new(:configs, :meta, keyword_init: true)
4
+ ConfigEnvelope = Struct.new(:configs, :meta, keyword_init: true) do
5
+ # qfg-9dxb.3: true when a decoded wire payload is a config envelope — a
6
+ # Hash carrying a +meta+ object with a non-empty +version+. api-delivery and
7
+ # `qfg serve` always send one; a `{}` or `{"error":"x"}` 200 from a
8
+ # misbehaving proxy/WAF does not, and must never be installed (it would
9
+ # read as "zero configs" and wipe every key on an established client).
10
+ def self.wire_envelope?(data)
11
+ return false unless data.is_a?(Hash)
12
+
13
+ meta = data['meta']
14
+ return false unless meta.is_a?(Hash)
15
+
16
+ version = meta['version']
17
+ !version.nil? && !version.to_s.empty?
18
+ end
19
+ end
5
20
  end
@@ -325,7 +325,11 @@ module Quonfig
325
325
  envelope = parse_envelope(response.body)
326
326
  result = install_envelope(envelope, source: source, source_index: index)
327
327
  # Write this leg's ETag back AFTER the response (per-leg, race-free).
328
- set_etag_for(index, new_etag)
328
+ # Exception (qfg-9dxb.9): an ignored gen<=0 payload keeps the previous
329
+ # ETag. Its ETag is the git sha, and the server may later repair the
330
+ # generation for the SAME sha; remembering it would 304 that repair.
331
+ ignored_unversioned = result == :not_modified && extract_generation(envelope.meta || {}) <= 0
332
+ set_etag_for(index, new_etag) unless ignored_unversioned
329
333
  # install_envelope returns :not_modified when the reject-older guard drops
330
334
  # an equal/older payload — surface that so the caller doesn't double-count.
331
335
  result == :not_modified ? :not_modified : :updated
@@ -339,6 +343,12 @@ module Quonfig
339
343
  @logger.info "Config fetch failed: status #{response.status} from #{source}"
340
344
  :failed
341
345
  end
346
+ rescue NonEnvelopeError => e
347
+ # qfg-9dxb.3: a non-envelope 200 is a leg error, NOT an install — the
348
+ # hedge/failover proceeds and this leg's ETag is never stored (so a junk
349
+ # 200 cannot pin itself through later 304s).
350
+ @logger.warn "Config fetch from #{source} returned a non-envelope 200 (#{e.message}); treating as a failed leg"
351
+ :failed
342
352
  rescue Faraday::ConnectionFailed => e
343
353
  @logger.debug "Connection failure fetching configs from #{source}: #{e.message}"
344
354
  :failed
@@ -355,8 +365,14 @@ module Quonfig
355
365
  @etag_mutex.synchronize { @etags[index || 0] = value }
356
366
  end
357
367
 
368
+ # Raised by #parse_envelope for a 200 whose body is not a config envelope
369
+ # (qfg-9dxb.3). Caught in #fetch_from as a leg error.
370
+ class NonEnvelopeError < StandardError; end
371
+
358
372
  def parse_envelope(body)
359
373
  data = body.is_a?(String) ? JSON.parse(body) : body
374
+ raise NonEnvelopeError, 'missing meta.version' unless Quonfig::ConfigEnvelope.wire_envelope?(data)
375
+
360
376
  Quonfig::ConfigEnvelope.new(
361
377
  configs: data['configs'] || [],
362
378
  meta: data['meta'] || {}
@@ -375,23 +391,37 @@ module Quonfig
375
391
  incoming_gen = extract_generation(meta)
376
392
 
377
393
  @install_mutex.synchronize do
378
- # Reject-older install guard (canonical ordering, §5f). A fresh client
379
- # (no held generation) seeds off whatever arrives first — even an older
380
- # or gen-0 snapshot. An established client installs ONLY when the incoming
381
- # generation strictly advances the held one: a same-generation snapshot is
382
- # a no-op (no store churn, no install-count bump, no resolved-from change)
383
- # so a duplicate leg never flaps an established client, and an OLDER
384
- # snapshot (a stale secondary reached on failover) is dropped so the client
385
- # never regresses. Reject-older is the whole rule — no source ranking; a
386
- # newer primary landing late heals forward automatically. Applies on every
387
- # network install path (initial fetch, failover/poll fetch, SSE snapshot,
388
- # SSE update, fallback poller); datadir install bypasses this (it is the
389
- # local source of truth and goes through Client#apply_datadir_envelope).
390
- # Carve-out: an UNVERSIONED snapshot (generation <= 0 — a server that
391
- # predates the watermark, or one whose rev-count failed) carries no
392
- # ordering info, so it is never rejected as "older"; freezing the client
393
- # on stale config would be worse (mirrors sdk-node).
394
- unless @held_generation.nil? || incoming_gen <= 0 || incoming_gen > @held_generation
394
+ # Reject-older install guard (canonical ordering, §5f; mirrors sdk-go
395
+ # shouldInstall). The rule:
396
+ # - fresh client (nothing installed yet) -> install, whatever arrives
397
+ # - incoming generation <= 0 (unversioned) -> install ONLY if the held
398
+ # generation is 0 (the client has never held a real generation)
399
+ # - otherwise -> install iff incoming strictly exceeds held
400
+ # A same-generation snapshot is a no-op (no store churn, no install-count
401
+ # bump, no resolved-from change) so a duplicate leg never flaps an
402
+ # established client, and an OLDER snapshot (a stale secondary reached on
403
+ # failover) is dropped so the client never regresses. Reject-older is the
404
+ # whole rule — no source ranking; a newer primary landing late heals
405
+ # forward automatically. Applies on every network install path (initial
406
+ # fetch, failover/poll fetch, SSE snapshot, SSE update, fallback poller);
407
+ # datadir install bypasses this (it is the local source of truth and goes
408
+ # through Client#apply_datadir_envelope).
409
+ #
410
+ # Unversioned payloads (qfg-9dxb.9): the pre-watermark servers that sent
411
+ # gen 0 on every payload are long gone. Today gen 0 comes only from a
412
+ # server whose git store is damaged (rev-count failed) — the least
413
+ # trustworthy source — so it must not override a held real generation.
414
+ # A client that has only ever seen gen 0 (e.g. `qfg serve`) keeps
415
+ # installing each gen 0 payload.
416
+ unless should_install?(incoming_gen)
417
+ if incoming_gen <= 0
418
+ # Unversioned payload while a real generation is held: not provably
419
+ # older (it carries no ordering info), so a silent no-op — NOT
420
+ # counted as guardRejected.
421
+ @logger.debug "Unversioned payload ignored: held generation #{@held_generation} (source=#{source})"
422
+ return :not_modified
423
+ end
424
+
395
425
  if incoming_gen < @held_generation
396
426
  @logger.debug "Reject-older guard: dropping incoming generation #{incoming_gen} < held #{@held_generation} (source=#{source})"
397
427
  # Failover observability (qfg-41nh.18): count the guard rejection.
@@ -431,7 +461,11 @@ module Quonfig
431
461
  @version = meta['version'] || meta[:version] || @version
432
462
  @environment_id = meta['environment'] || meta[:environment] || @environment_id
433
463
 
434
- @held_generation = incoming_gen
464
+ # qfg-9dxb.3 Fix A: an unversioned install (generation <= 0) carries no
465
+ # ordering info and must never LOWER a positive held generation. Under
466
+ # the qfg-9dxb.9 rule it only installs when held is nil/0 anyway, so
467
+ # this keeps held at 0 there; a fresh client seeds off it as usual.
468
+ @held_generation = incoming_gen if @held_generation.nil? || incoming_gen.positive?
435
469
  @install_count += 1
436
470
  @resolved_from_index = source_index unless source_index.nil?
437
471
  # Failover observability (qfg-41nh.18): record which leg served this
@@ -457,6 +491,14 @@ module Quonfig
457
491
  end
458
492
  end
459
493
 
494
+ # The install decision for a network payload (see #install_envelope).
495
+ def should_install?(incoming_gen)
496
+ return true if @held_generation.nil?
497
+ return @held_generation.zero? if incoming_gen <= 0
498
+
499
+ incoming_gen > @held_generation
500
+ end
501
+
460
502
  # Read Meta.generation (qfg-7h5d.1.1) — the monotonic per-branch commit
461
503
  # counter the backend stamps on every envelope. Absent/garbage → 0 (an old
462
504
  # backend that doesn't emit it, or fixture mode with no FIXTURE_GENERATION).
@@ -131,10 +131,15 @@ module Quonfig
131
131
 
132
132
  # --- Rule evaluation ------------------------------------------------
133
133
 
134
- def evaluate_rules(rules, context, config)
134
+ # +seg_path+ holds the keys of the configs being evaluated above this one
135
+ # through IN_SEG / NOT_IN_SEG, so segment resolution can detect a
136
+ # reference cycle instead of recursing until SystemStackError
137
+ # (qfg-9dxb.7). It is a path, not a global visited set, so a diamond (two
138
+ # segments that both reference a third) still resolves.
139
+ def evaluate_rules(rules, context, config, seg_path = [])
135
140
  rules.each_with_index do |rule, index|
136
141
  criteria = Array(hget(rule, :criteria) || [])
137
- next unless all_criteria_match?(criteria, context, config)
142
+ next unless all_criteria_match?(criteria, context, config, seg_path)
138
143
 
139
144
  value_hash = hget(rule, :value)
140
145
  return EvalResult.new(value: value_hash, rule_index: index, config: config)
@@ -142,8 +147,8 @@ module Quonfig
142
147
  nil
143
148
  end
144
149
 
145
- def all_criteria_match?(criteria, context, config)
146
- criteria.all? { |c| evaluate_criterion(c, context, config) }
150
+ def all_criteria_match?(criteria, context, config, seg_path)
151
+ criteria.all? { |c| evaluate_criterion(c, context, config, seg_path) }
147
152
  end
148
153
 
149
154
  # --- Per-operator evaluation ---------------------------------------
@@ -151,7 +156,7 @@ module Quonfig
151
156
  # Faithful port of sdk-node/src/operators.ts evaluateCriterion. Matches
152
157
  # context-exists / missing-context semantics (e.g. PROP_IS_NOT_ONE_OF is
153
158
  # true when context is missing).
154
- def evaluate_criterion(criterion, context, _config)
159
+ def evaluate_criterion(criterion, context, config, seg_path)
155
160
  property_name = hget(criterion, :propertyName) || ''
156
161
  operator = hget(criterion, :operator)
157
162
  match_value = hget(criterion, :valueToMatch)
@@ -296,7 +301,7 @@ module Quonfig
296
301
  when OP_IN_SEG, OP_NOT_IN_SEG
297
302
  if match_value
298
303
  segment_key = to_s_nil(hget(match_value, :value))
299
- found, result = resolve_segment(segment_key, context)
304
+ found, result = resolve_segment(segment_key, context, config, seg_path)
300
305
  return operator == OP_NOT_IN_SEG unless found
301
306
 
302
307
  return result == (operator == OP_IN_SEG)
@@ -319,16 +324,22 @@ module Quonfig
319
324
 
320
325
  # --- Segment resolution -------------------------------------------
321
326
 
322
- def resolve_segment(segment_key, context)
327
+ def resolve_segment(segment_key, context, config, seg_path)
323
328
  return [false, false] if segment_key.nil? || segment_key.empty?
324
329
 
330
+ # A reference back onto the current evaluation path is a cycle. Treat
331
+ # it like a missing segment (IN_SEG false, NOT_IN_SEG true), matching
332
+ # sdk-go (qfg-9dxb.4).
333
+ current_key = hget(config, :key).to_s
334
+ return [false, false] if segment_key == current_key || seg_path.include?(segment_key)
335
+
325
336
  seg_config = @store.get(segment_key)
326
337
  return [false, false] if seg_config.nil?
327
338
 
328
339
  # Segments have no environment-specific rules in the JSON shape; we
329
340
  # evaluate against default rules only (mirrors sdk-node behaviour —
330
341
  # evaluate_config with env_id='' falls through to default).
331
- match = evaluate_rules(default_rules_of(seg_config), context, seg_config)
342
+ match = evaluate_rules(default_rules_of(seg_config), context, seg_config, seg_path + [current_key])
332
343
  return [false, false] if match.nil?
333
344
 
334
345
  raw = match.raw_value
@@ -424,14 +435,18 @@ module Quonfig
424
435
  REASON_TARGETING_MATCH = 2
425
436
  REASON_SPLIT = 3
426
437
 
427
- attr_reader :value, :rule_index, :config, :reportable_value
438
+ attr_reader :value, :rule_index, :config, :reportable_value, :hash_property_missing
428
439
  attr_accessor :weighted_value_index
429
440
 
430
- def initialize(value:, rule_index:, config:, weighted_value_index: nil, reportable_value: nil)
441
+ def initialize(value:, rule_index:, config:, weighted_value_index: nil, reportable_value: nil,
442
+ hash_property_missing: false)
431
443
  @value = value
432
444
  @rule_index = rule_index
433
445
  @config = config
434
446
  @weighted_value_index = weighted_value_index
447
+ # True when a weighted rollout's hash property was missing from the
448
+ # context and an empty value was hashed instead (qfg-9dxb.8).
449
+ @hash_property_missing = hash_property_missing
435
450
  # Telemetry-safe substitute for #unwrapped_value. Set by Resolver when
436
451
  # the underlying Value was confidential / decryptWith, so callers
437
452
  # (the eval-summary aggregator) never see the plaintext. Mirrors
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest'
4
+ require 'set'
4
5
 
5
6
  module Quonfig
6
7
  # Public-API resolver: looks up a config by key in a ConfigStore and runs
@@ -16,6 +17,7 @@ module Quonfig
16
17
  # production read path (with config_loader, SSE updates, telemetry), see
17
18
  # Quonfig::ConfigResolver — the two coexist during the JSON migration.
18
19
  class Resolver
20
+ LOG = Quonfig::InternalLogger.new(self)
19
21
  TRUE_VALUES = %w[true 1 t yes].freeze
20
22
  # Prefix the eval-summary aggregator stamps onto redacted confidential
21
23
  # values before the 5-char MD5 hash. Matches CONFIDENTIAL_PREFIX in
@@ -29,6 +31,9 @@ module Quonfig
29
31
  def initialize(store, evaluator)
30
32
  @store = store
31
33
  @evaluator = evaluator
34
+ # Config keys already warned about a missing hash property (qfg-9dxb.8).
35
+ @hash_missing_warned = Set.new
36
+ @hash_missing_mutex = Mutex.new
32
37
  end
33
38
 
34
39
  def raw(key)
@@ -50,7 +55,8 @@ module Quonfig
50
55
  return nil if eval_result.nil?
51
56
 
52
57
  weighted_index = nil
53
- resolved_value = resolve_value(eval_result.value, config, context) do |idx|
58
+ hash_missing = []
59
+ resolved_value = resolve_value_on_path(eval_result.value, config, context, [], hash_missing) do |idx|
54
60
  weighted_index = idx
55
61
  end
56
62
  EvalResult.new(
@@ -58,7 +64,8 @@ module Quonfig
58
64
  rule_index: eval_result.rule_index,
59
65
  config: config,
60
66
  weighted_value_index: weighted_index,
61
- reportable_value: redacted_reportable_value(eval_result.value)
67
+ reportable_value: redacted_reportable_value(eval_result.value),
68
+ hash_property_missing: !hash_missing.empty?
62
69
  )
63
70
  end
64
71
 
@@ -68,32 +75,42 @@ module Quonfig
68
75
  # - confidential + decryptWith → look up the key config, decrypt
69
76
  # - everything else passes through unchanged
70
77
  def resolve_value(value, config, context = nil, &on_weighted_index)
78
+ resolve_value_on_path(value, config, context, [], &on_weighted_index)
79
+ end
80
+
81
+ # Integration shims for code that expects a ConfigResolver. Keep these
82
+ # narrow; the real ConfigResolver still owns the production hot path.
83
+ def symbolize_json_names?
84
+ false
85
+ end
86
+
87
+ private
88
+
89
+ # resolve_value plus +key_path+: the config keys already being resolved
90
+ # above this one through decryptWith, so a decryptWith cycle raises a
91
+ # DecryptionError instead of recursing until SystemStackError
92
+ # (qfg-9dxb.7, matching sdk-go qfg-9dxb.4). +hash_missing+, when given,
93
+ # gets an entry for each weighted rollout whose hash property was missing
94
+ # from the context (qfg-9dxb.8).
95
+ def resolve_value_on_path(value, config, context, key_path, hash_missing = nil, &on_weighted_index)
71
96
  return nil if value.nil?
72
97
 
73
98
  type = vget(value, :type, 'type')
74
99
 
75
100
  return resolve_provided(value, config) if type == 'provided'
76
101
 
77
- return resolve_weighted(value, config, context, &on_weighted_index) if type == 'weighted_values'
102
+ return resolve_weighted(value, config, context, key_path, hash_missing, &on_weighted_index) if type == 'weighted_values'
78
103
 
79
104
  confidential = vget(value, :confidential, 'confidential')
80
105
  decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
81
106
  if confidential && decrypt_with && !decrypt_with.to_s.empty?
82
107
  return resolve_decryption(value, config, context,
83
- decrypt_with)
108
+ decrypt_with, key_path)
84
109
  end
85
110
 
86
111
  value
87
112
  end
88
113
 
89
- # Integration shims for code that expects a ConfigResolver. Keep these
90
- # narrow; the real ConfigResolver still owns the production hot path.
91
- def symbolize_json_names?
92
- false
93
- end
94
-
95
- private
96
-
97
114
  # If +value+ is confidential or has a decryptWith key, return the
98
115
  # `*****<5-hex>` redacted string the eval-summary telemetry aggregator
99
116
  # should ship in place of the resolved plaintext. The hash is computed
@@ -162,7 +179,12 @@ module Quonfig
162
179
  # (or fall back to a per-call random) into [0,1), then walk the variant
163
180
  # weights until cumulative weight >= bucket. Recurses through
164
181
  # resolve_value so nested provided/encrypted variants work too.
165
- def resolve_weighted(value, config, context, &on_weighted_index)
182
+ #
183
+ # A hash property that is configured but missing from the context (or
184
+ # nil) hashes an empty value, the same bucket as a present "", and warns
185
+ # once per config key (qfg-9dxb.8). With no hash property configured the
186
+ # pick stays random per call.
187
+ def resolve_weighted(value, config, context, key_path, hash_missing = nil, &on_weighted_index)
166
188
  payload = vget(value, :value, 'value') || {}
167
189
  weighted = vget(payload, :weightedValues, 'weightedValues', :weighted_values, 'weighted_values')
168
190
  return value unless weighted.is_a?(Array) && !weighted.empty?
@@ -182,23 +204,42 @@ module Quonfig
182
204
  end
183
205
 
184
206
  cfg_key = config_key(config)
207
+ if hash_value.nil? && !hash_property.to_s.empty?
208
+ hash_value = ''
209
+ hash_missing&.push(true)
210
+ warn_hash_property_missing(cfg_key, hash_property)
211
+ end
185
212
  picker = Quonfig::WeightedValueResolver.new(weighted, cfg_key, hash_value)
186
213
  variant, index = picker.resolve
187
214
  on_weighted_index&.call(index)
188
215
  variant_value = vget(variant, :value, 'value')
189
- resolve_value(variant_value, config, context, &on_weighted_index)
216
+ resolve_value_on_path(variant_value, config, context, key_path, hash_missing, &on_weighted_index)
217
+ end
218
+
219
+ def warn_hash_property_missing(cfg_key, hash_property)
220
+ return unless @hash_missing_mutex.synchronize { @hash_missing_warned.add?(cfg_key) }
221
+
222
+ LOG.warn "quonfig: weighted rollout for \"#{cfg_key}\" hashes on \"#{hash_property}\" " \
223
+ 'which is missing from context; hashing an empty value instead'
190
224
  end
191
225
 
192
226
  # Recursively resolve the decryption-key config (it may itself be a
193
227
  # provided ENV_VAR), then AES-GCM decrypt the value with that key.
194
- def resolve_decryption(value, config, context, decrypt_with)
228
+ def resolve_decryption(value, config, context, decrypt_with, key_path)
229
+ key_path += [config_key(config).to_s]
230
+ if key_path.include?(decrypt_with.to_s)
231
+ raise Quonfig::Errors::DecryptionError.new(
232
+ config_key(config), %(decryption key config "#{decrypt_with}" is part of a decryptWith cycle)
233
+ )
234
+ end
235
+
195
236
  key_cfg = @store.get(decrypt_with)
196
237
  raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" not found) if key_cfg.nil?
197
238
 
198
239
  key_match = @evaluator.evaluate_config(key_cfg, context, resolver: self)
199
240
  raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" did not match) if key_match.nil?
200
241
 
201
- resolved_key = resolve_value(key_match.value, key_cfg, context)
242
+ resolved_key = resolve_value_on_path(key_match.value, key_cfg, context, key_path)
202
243
  secret_key = vget(resolved_key, :value, 'value').to_s
203
244
  raise Quonfig::Error, %(Decryption key from "#{decrypt_with}" is empty) if secret_key.empty?
204
245
 
@@ -604,6 +604,14 @@ module Quonfig
604
604
  return nil
605
605
  end
606
606
 
607
+ # qfg-9dxb.3: drop a JSON event that is not a config envelope (no meta
608
+ # object with a non-empty version) exactly like malformed JSON — it must
609
+ # never reach install and wipe an established client's keys.
610
+ unless Quonfig::ConfigEnvelope.wire_envelope?(parsed)
611
+ (@logger || LOG).error 'SSE Streaming Error: non-envelope event (missing meta.version); dropped'
612
+ return nil
613
+ end
614
+
607
615
  envelope = Quonfig::ConfigEnvelope.new(
608
616
  configs: parsed['configs'] || [],
609
617
  meta: parsed['meta'] || {}
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.5.0'
4
+ VERSION = '1.6.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quonfig
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Dwyer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-25 00:00:00.000000000 Z
11
+ date: 2026-09-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport