quonfig 1.5.0 → 1.7.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: ea0ee1cb9b012622117d24f82d00e9ce5fdf61d160562846c6c9b6fa03b4a524
4
+ data.tar.gz: 2aa051314de0c538eb77218e015368dab3c4fd5c0405ea5469b75c84817314c3
5
5
  SHA512:
6
- metadata.gz: 7616048fcd0a2a0727ac79a0a801f35d71be44cd54667dc4aaa8aa86009033194f20abbfb69f0b959dc5ba90cdf3e29069db1384e8a432caafe34898f69e3165
7
- data.tar.gz: 31d412725f88752b8cbde55daa05c99b926212c2951519a177c24dae72b19d665b3452f4f723e5b2b7e78258cdb37e064733e18249316f7b055846408d2eda92
6
+ metadata.gz: 0a0d54912f70d45cec55a53c758636d8bb7448eac31c1aee6a19463e6575247e8bc0374ed9ff3b279630b00031556dacb6657ffef7a9f16ac87ec5eb2a2f65ef
7
+ data.tar.gz: 8838087ac35f83bc5c8761cba0c58a1a8a3fc1a0b3472c6de0b81c20a71d950f4ec1759098ce72e129fd6b4dadd8dd5b6190416d58014cf9dffb99b2bfc9cdd0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0 - 2026-10-02
4
+
5
+ - **New: public `Client#get_or_raise` and `Client#get_duration_details` (also on `BoundClient`) (qfg-2agi.27).** `get_or_raise(key, default:, context:)` returns the value like `get`; a missing key with no `default:` raises `MissingDefaultError` even under `on_no_default: :return_nil`, and an unset ENV_VAR (`MissingEnvVarError`), an uncoercible ENV_VAR or malformed stored duration (`EnvVarParseError`) or a decryption failure (`DecryptionError`) raises. `get_duration_details` returns integer milliseconds in `EvaluationDetails`; a non-duration config is `ERROR`/`TYPE_MISMATCH` and a malformed value is `ERROR` with a nil value. The shared integration suite's init-timeout `get_or_raise` case now calls the public method instead of falling back to `get`.
6
+ - **Fix: durations follow the one grammar all Quonfig SDKs share, and a malformed duration returns the default instead of a made-up number (qfg-2agi.10).** Grammar (integration-test-data `tests/duration/grammar.yaml`): `P[nD][T[nH][nM][n[.fff]S]]`, at least one component, a fraction only on seconds (at most 9 digits), at most `P36500D`. Before, the pattern was unanchored, so `xxPT5Sxx` read as 5000 ms, `PT5M3H` as 300000 ms and `garbage` as 0. Milliseconds are now rounded half up on both the stored and the ENV_VAR path (`PT1.9999S` from an env var was 1999, now 2000).
7
+ - `get_duration` on a malformed value (stored or ENV_VAR) returns `default:` and logs one warning per key. With no default it returns `nil` under `on_no_default: :return_nil` and raises `Quonfig::Errors::EnvVarParseError` under the default `:raise` policy.
8
+ - `get_duration` on a config whose value type is not duration now raises `TypeMismatchError`, like `get_int` on a string config. Before, an int config's number was returned as milliseconds.
9
+ - `get` on a malformed stored duration returns `nil` (was `0` or a wrong number).
10
+ - `Quonfig::Duration.parse` raises `ArgumentError` on an invalid string (it returned `0`). New `Quonfig::Duration.parse_millis` (Integer ms or `nil`) and `Quonfig::Duration.valid?`. Values that used fractions on D/H/M (`PT0.5H`, `PT1.5M`) are now invalid; write `PT30M`, `PT90S`.
11
+ - **Fix: `Client#get` on a duration provided by an ENV_VAR now returns integer milliseconds, the same as a stored duration (qfg-2agi.22).** Before, it returned the raw ISO string (`PT30M` came back as `"PT30M"` from the env var but `1800000` when stored). `get_duration` was already correct.
12
+ - **Behaviour change (bug fix): a named context from a newer tier now replaces the whole same-named context from an older tier, instead of being merged into it property by property (qfg-2agi.36).** Tiers, oldest to newest: `global_context`, `with_context` / `in_context` scopes (outer to inner), per-call context. Example: with `global_context: { user: { 'role' => 'admin' } }`, a call passing `user: { 'plan' => 'pro' }` now evaluates with `user.plan` only; `user.role` is no longer present. Named contexts the newer tier does not mention (for example `team`) are still kept. A symbol name and a string name (`:user` vs `'user'`) count as the same named context. This is the documented rule. Callers that relied on the old property-level merge should pass every attribute of a named context they set.
13
+ - **Fix (security): evaluation-summary telemetry no longer sends the plaintext of `confidential` or `decryptWith` config values (qfg-2agi.15).** `selectedValue` now carries the `*****<hash>` redacted form the resolver already computed; before, `Client#get` reported the decrypted value. Non-confidential values are reported unchanged.
14
+ - **Tests: the shared integration suite now drives every case through the public `Quonfig::Client` (qfg-2agi.33).** Each case uses a datadir client and the typed getter named by its `type:` (`get_string`, `get_int`, `get_float`, `get_bool`, `get_string_list`, `get_json`, `get_duration`), `enabled?` or `get_or_raise`. Context tiers go through `global_context:`, `with_context` and `in_context`. Telemetry cases flush the client's real reporter to a local HTTP server and check the POST. Before, most cases called the internal resolver, and the harness redacted telemetry values itself, so a broken typed getter, a negated `enabled?` or dropped redaction left the suite green. No library change.
15
+ - **Tests: the shared integration suite now checks every duration case through the public `Client#get_duration`, with an exact integer millisecond comparison (qfg-2agi.4).** Before, it checked the internal resolver, so a bug in `get_duration` could not fail the suite. No library change.
16
+
17
+ ## 1.6.0 - 2026-09-28
18
+
19
+ - **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.
20
+ - **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.
21
+ - **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`.
22
+ - **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.
23
+
3
24
  ## 1.5.0 - 2026-09-25
4
25
 
5
26
  - **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.
data/README.md CHANGED
@@ -259,7 +259,7 @@ Quonfig::Client.new(
259
259
  fallback_poll_enabled: true,
260
260
  fallback_poll_interval_ms: 60_000,
261
261
  init_timeout_ms: 10_000,
262
- on_no_default: :error,
262
+ on_no_default: :raise,
263
263
  global_context: {},
264
264
  datadir: '/path/to/workspace',
265
265
  environment: 'production',
@@ -277,7 +277,7 @@ Quonfig::Client.new(
277
277
  | `fallback_poll_enabled` | `Boolean` | `true` | Engage HTTP polling as a fallback when SSE is unavailable for >= 2x `fallback_poll_interval_ms`. Deprecated alias: `enable_polling`. |
278
278
  | `fallback_poll_interval_ms` | `Integer` (ms) | `60_000` | Interval between fallback HTTP polls, in milliseconds. Deprecated alias: `poll_interval` (seconds, multiplied by 1000 internally). |
279
279
  | `init_timeout_ms` | `Integer` (ms) | `10_000` | Maximum time to wait for the initial config load, in milliseconds. Deprecated alias: `initialization_timeout_sec` (seconds, multiplied by 1000 internally). |
280
- | `on_no_default` | `Symbol` | `:error` | Behavior when a key has no value and no default: `:error`, `:warn`, or `:ignore`. |
280
+ | `on_no_default` | `Symbol` | `:raise` | Behavior when a key has no value and no default: `:raise` (raise `Quonfig::Errors::MissingDefaultError`) or `:return_nil`. |
281
281
  | `global_context` | `Hash` | `{}` | Context applied to every evaluation. |
282
282
  | `datadir` | `String` | `ENV['QUONFIG_DIR']` | Path to a local workspace. When set, the SDK runs offline from disk. |
283
283
  | `environment` | `String` | `ENV['QUONFIG_ENVIRONMENT']` | Environment to evaluate in datadir mode. Required when `datadir` is set. |
@@ -361,6 +361,14 @@ client.get_json('homepage.layout')
361
361
  client.enabled?('beta-feature', user: { key: 'user-123' })
362
362
  ```
363
363
 
364
+ `get_or_raise(key, default:, context:)` returns the value like `get`, but a
365
+ missing key without a `default:` always raises
366
+ `Quonfig::Errors::MissingDefaultError` (whatever `on_no_default` is set to), and
367
+ an unset or malformed ENV_VAR value, a malformed stored duration, or a
368
+ decryption failure raises its error. `get_duration_details(key, context:)`
369
+ returns `Quonfig::EvaluationDetails` with the duration in integer milliseconds,
370
+ like the other `*_details` getters.
371
+
364
372
  ## Dynamic log levels (SemanticLogger)
365
373
 
366
374
  Quonfig can drive per-class log levels at runtime. Set config keys like
@@ -40,6 +40,10 @@ module Quonfig
40
40
  @client.get_json(key, default: default, context: @context)
41
41
  end
42
42
 
43
+ def get_or_raise(key, default: NO_DEFAULT_PROVIDED)
44
+ @client.get_or_raise(key, default: default, context: @context)
45
+ end
46
+
43
47
  # ---- Details getters ----------------------------------------------
44
48
 
45
49
  def get_bool_details(key)
@@ -66,15 +70,18 @@ module Quonfig
66
70
  @client.get_json_details(key, context: @context)
67
71
  end
68
72
 
73
+ def get_duration_details(key)
74
+ @client.get_duration_details(key, context: @context)
75
+ end
76
+
69
77
  def enabled?(feature_name)
70
78
  @client.enabled?(feature_name, @context)
71
79
  end
72
80
 
73
- # Returns a new BoundClient whose bound context is the merge of this
74
- # bound context and +additional+. Merge is one level deep per named
75
- # context (mirrors sdk-node's mergeContexts): later values override
76
- # earlier within the same named context; keys unique to each side are
77
- # preserved.
81
+ # Returns a new BoundClient whose bound context is this bound context
82
+ # plus +additional+ (qfg-2agi.24): a named context in +additional+
83
+ # REPLACES the whole same-named context (no per-property merge); named
84
+ # contexts it does not mention are kept. Names compare as strings.
78
85
  def in_context(additional)
79
86
  self.class.new(@client, merge_contexts(@context, additional || {}))
80
87
  end
@@ -87,9 +94,10 @@ module Quonfig
87
94
 
88
95
  def merge_contexts(left, right)
89
96
  merged = {}
90
- left.each { |name, ctx| merged[name] = ctx.dup }
97
+ left.each { |name, ctx| merged[name] = ctx.dup }
91
98
  right.each do |name, ctx|
92
- merged[name] = merged[name] ? merged[name].merge(ctx) : ctx.dup
99
+ merged.delete_if { |existing, _| existing.to_s == name.to_s }
100
+ merged[name] = ctx.dup
93
101
  end
94
102
  merged
95
103
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'timeout'
5
+ require 'set'
5
6
 
6
7
  module Quonfig
7
8
  # Public Quonfig SDK client.
@@ -63,6 +64,9 @@ module Quonfig
63
64
  @evaluator = Quonfig::Evaluator.new(@store, env_id: @options.environment)
64
65
  @resolver = Quonfig::Resolver.new(@store, @evaluator)
65
66
  @semantic_logger_filters = {}
67
+ # Keys already warned about for a malformed duration value (qfg-2agi.10).
68
+ @malformed_duration_warned = Set.new
69
+ @malformed_duration_mutex = Mutex.new
66
70
  @sse_client = nil
67
71
  @poll_supervisor = nil
68
72
  @stopped = false
@@ -161,14 +165,70 @@ module Quonfig
161
165
  typed_get(key, :string_list, default: default, context: context)
162
166
  end
163
167
 
168
+ # Integer milliseconds for a duration config (qfg-2agi.10).
169
+ #
170
+ # - A config whose valueType is not duration raises TypeMismatchError,
171
+ # like get_int on a string config.
172
+ # - Values follow the shared grammar (Quonfig::Duration); ms are rounded
173
+ # half up on both the stored and the ENV_VAR path.
174
+ # - A malformed value (stored or ENV_VAR) returns +default+ and logs one
175
+ # warning per key. With no default it returns nil under
176
+ # on_no_default: :return_nil and raises EnvVarParseError under :raise.
164
177
  def get_duration(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
165
- typed_get(key, :duration, default: default, context: context)
178
+ ensure_initialized_after_fork
179
+ ctx = build_context(context)
180
+ record_context_for_telemetry(ctx)
181
+ result =
182
+ begin
183
+ @resolver.get(key, ctx)
184
+ rescue Quonfig::Errors::MissingDefaultError
185
+ nil
186
+ rescue Quonfig::Errors::EnvVarParseError => e
187
+ return malformed_duration(key, default, e)
188
+ end
189
+ return handle_missing(key, default) if result.nil?
190
+
191
+ record_evaluation_for_telemetry(result)
192
+ duration_millis(key, result, default)
166
193
  end
167
194
 
168
195
  def get_json(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
169
196
  typed_get(key, :json, default: default, context: context)
170
197
  end
171
198
 
199
+ # Like +get+, but never applies the +on_no_default+ policy (qfg-2agi.27):
200
+ #
201
+ # - A missing key (or a config with no matching value) returns +default:+
202
+ # when one is given and otherwise raises MissingDefaultError, even
203
+ # under on_no_default: :return_nil.
204
+ # - A config provided by an unset ENV_VAR raises MissingEnvVarError; an
205
+ # ENV_VAR value that cannot be coerced to the config's type raises
206
+ # EnvVarParseError; a decryption failure raises DecryptionError.
207
+ # - A malformed stored duration raises EnvVarParseError (a given
208
+ # +default:+ is not applied; it only covers a missing key).
209
+ def get_or_raise(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
210
+ ensure_initialized_after_fork
211
+ ctx = build_context(context)
212
+ record_context_for_telemetry(ctx)
213
+ result =
214
+ begin
215
+ @resolver.get(key, ctx)
216
+ rescue Quonfig::Errors::MissingDefaultError
217
+ nil
218
+ end
219
+ if result.nil?
220
+ raise Quonfig::Errors::MissingDefaultError, key if default == NO_DEFAULT_PROVIDED
221
+
222
+ return default
223
+ end
224
+
225
+ record_evaluation_for_telemetry(result)
226
+ value = result.unwrapped_value
227
+ raise malformed_stored_duration_error(result) if malformed_stored_duration?(result, value)
228
+
229
+ value
230
+ end
231
+
172
232
  # ---- Details getters ----------------------------------------------
173
233
  #
174
234
  # Mirrors the typed getters above but returns a +Quonfig::EvaluationDetails+
@@ -201,6 +261,13 @@ module Quonfig
201
261
  evaluate_details(key, :json, context)
202
262
  end
203
263
 
264
+ # Integer milliseconds in +value+ (qfg-2agi.27). A config whose valueType
265
+ # is not duration is ERROR / TYPE_MISMATCH; a malformed value (stored or
266
+ # ENV_VAR) is ERROR with a nil value.
267
+ def get_duration_details(key, context: NO_DEFAULT_PROVIDED)
268
+ evaluate_details(key, :duration, context)
269
+ end
270
+
204
271
  def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
205
272
  value = get(feature_name, false, jit_context)
206
273
  [true, 'true'].include?(value)
@@ -1153,7 +1220,9 @@ module Quonfig
1153
1220
  config_type: config_field(config, :type),
1154
1221
  conditional_value_index: result.rule_index,
1155
1222
  weighted_value_index: result.weighted_value_index,
1156
- selected_value: result.unwrapped_value,
1223
+ # Confidential / decryptWith values ship as the `*****<hash>` redacted
1224
+ # form, never the plaintext (qfg-2agi.15).
1225
+ selected_value: result.reportable_value.nil? ? result.unwrapped_value : result.reportable_value,
1157
1226
  reason: result.wire_reason
1158
1227
  )
1159
1228
  rescue StandardError => e
@@ -1516,7 +1585,7 @@ module Quonfig
1516
1585
 
1517
1586
  # Combine the customer-supplied globalContext with the optional dev
1518
1587
  # context loaded from ~/.quonfig/tokens.json. Dev context goes UNDER the
1519
- # customer's so any explicit `quonfig-user` keys win on collision.
1588
+ # customer's, so an explicit customer `quonfig-user` context replaces it.
1520
1589
  def build_initial_global_context(options)
1521
1590
  customer = normalize_context(options.global_context)
1522
1591
  return customer unless dev_context_enabled?(options)
@@ -1549,21 +1618,19 @@ module Quonfig
1549
1618
  raise ArgumentError, "Quonfig context must be a Hash, got #{ctx.class}"
1550
1619
  end
1551
1620
 
1552
- # One-level-deep merge per named context (mirrors sdk-node's mergeContexts):
1553
- # later values override earlier within the same named context; keys unique
1554
- # to each side are preserved.
1621
+ # Combine two context tiers (qfg-2agi.24): a named context in +right+
1622
+ # REPLACES the whole same-named context in +left+ (no per-property merge);
1623
+ # named contexts only in +left+ survive. Names compare as strings, so
1624
+ # +:user+ and +'user'+ are the same named context.
1555
1625
  def merge_contexts(left, right)
1556
1626
  return right || {} if left.nil? || left.empty?
1557
1627
  return left if right.nil? || right.empty?
1558
1628
 
1559
1629
  merged = {}
1560
- left.each { |name, ctx| merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx }
1630
+ left.each { |name, ctx| merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx }
1561
1631
  right.each do |name, ctx|
1562
- merged[name] = if merged[name].is_a?(Hash) && ctx.is_a?(Hash)
1563
- merged[name].merge(ctx)
1564
- else
1565
- ctx.is_a?(Hash) ? ctx.dup : ctx
1566
- end
1632
+ merged.delete_if { |existing, _| existing.to_s == name.to_s }
1633
+ merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx
1567
1634
  end
1568
1635
  merged
1569
1636
  end
@@ -1623,6 +1690,10 @@ module Quonfig
1623
1690
  raw_value = result.unwrapped_value
1624
1691
 
1625
1692
  begin
1693
+ if expected_type == :duration
1694
+ raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', raw_value) unless duration_result?(result)
1695
+ raise malformed_stored_duration_error(result) if malformed_stored_duration?(result, raw_value)
1696
+ end
1626
1697
  coerced = coerce_and_check(key, raw_value, expected_type) unless raw_value.nil?
1627
1698
  rescue Quonfig::Errors::TypeMismatchError => e
1628
1699
  return Quonfig::EvaluationDetails.new(
@@ -1636,13 +1707,15 @@ module Quonfig
1636
1707
  end
1637
1708
 
1638
1709
  reason = result.of_reason
1710
+ flag_metadata = build_flag_metadata(
1711
+ config_id, config_type, result.rule_index, result.weighted_value_index, reason
1712
+ )
1713
+ flag_metadata['hashPropertyMissing'] = true if result.hash_property_missing
1639
1714
  Quonfig::EvaluationDetails.new(
1640
1715
  value: coerced,
1641
1716
  reason: reason,
1642
1717
  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
- )
1718
+ flag_metadata: flag_metadata
1646
1719
  )
1647
1720
  rescue StandardError => e
1648
1721
  Quonfig::EvaluationDetails.new(
@@ -1699,6 +1772,42 @@ module Quonfig
1699
1772
  coerce_and_check(key, value, expected_type)
1700
1773
  end
1701
1774
 
1775
+ def duration_millis(key, result, default)
1776
+ raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', result.unwrapped_value) unless duration_result?(result)
1777
+
1778
+ value = result.unwrapped_value
1779
+ millis = value.is_a?(Integer) ? value : Quonfig::Duration.parse_millis(value)
1780
+ return millis unless millis.nil?
1781
+
1782
+ malformed_duration(key, default, malformed_stored_duration_error(result))
1783
+ end
1784
+
1785
+ def duration_result?(result)
1786
+ config = result.config || {}
1787
+ value_type = config['valueType'] || config[:valueType] || config['value_type'] || config[:value_type]
1788
+ value_type == 'duration' || result.type == 'duration'
1789
+ end
1790
+
1791
+ # A stored duration string outside the shared grammar unwraps to nil.
1792
+ def malformed_stored_duration?(result, unwrapped)
1793
+ unwrapped.nil? && result.type == 'duration' && !result.raw_value.nil?
1794
+ end
1795
+
1796
+ def malformed_stored_duration_error(result)
1797
+ Quonfig::Errors::EnvVarParseError.new(result.raw_value, result.config || {}, 'stored value')
1798
+ end
1799
+
1800
+ def malformed_duration(key, default, error)
1801
+ if @malformed_duration_mutex.synchronize { @malformed_duration_warned.add?(key.to_s) }
1802
+ LOG.warn "[quonfig] #{key} is not a valid ISO-8601 duration; " \
1803
+ "#{default == NO_DEFAULT_PROVIDED ? 'no default given' : 'returning the default'}"
1804
+ end
1805
+ return default unless default == NO_DEFAULT_PROVIDED
1806
+ raise error if @options.on_no_default == Quonfig::Options::ON_NO_DEFAULT::RAISE
1807
+
1808
+ nil
1809
+ end
1810
+
1702
1811
  def coerce_and_check(key, value, expected_type)
1703
1812
  case expected_type
1704
1813
  when :bool
@@ -1711,10 +1820,12 @@ module Quonfig
1711
1820
 
1712
1821
  arr
1713
1822
  when :duration
1714
- return value.to_i if value.is_a?(Numeric)
1715
- return (Quonfig::Duration.parse(value) * 1000).to_i if value.is_a?(String)
1823
+ return value if value.is_a?(Integer)
1824
+
1825
+ millis = Quonfig::Duration.parse_millis(value)
1826
+ raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', value) if millis.nil?
1716
1827
 
1717
- raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', value)
1828
+ millis
1718
1829
  when :json
1719
1830
  # JSON values are returned as-is (Hash, Array, or scalar from the wire).
1720
1831
  value
@@ -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).
@@ -1,27 +1,76 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
+ # ISO-8601 duration parsing for the grammar every Quonfig SDK shares
5
+ # (integration-test-data/tests/duration/grammar.yaml, qfg-2agi.29):
6
+ #
7
+ # \AP(?:[0-9]+D)?(?:T(?:[0-9]+H)?(?:[0-9]+M)?(?:[0-9]+(?:\.[0-9]{1,9})?S)?)?\z
8
+ #
9
+ # plus: at least one component, no dangling T, a fraction only on S (at
10
+ # most 9 digits), total magnitude <= P36500D. Anchored with \A/\z (Ruby's
11
+ # ^/$ match at line boundaries) and [0-9] (never \d).
12
+ #
13
+ # Milliseconds use exact Rational arithmetic, rounded half up.
4
14
  class Duration
5
- PATTERN = /P(?:(?<days>\d+(?:\.\d+)?)D)?(?:T(?:(?<hours>\d+(?:\.\d+)?)H)?(?:(?<minutes>\d+(?:\.\d+)?)M)?(?:(?<seconds>\d+(?:\.\d+)?)S)?)?/
15
+ PATTERN = /\AP(?:(?<days>[0-9]+)D)?(?:T(?<time>(?:(?<hours>[0-9]+)H)?(?:(?<minutes>[0-9]+)M)?(?:(?<seconds>[0-9]+(?:\.[0-9]{1,9})?)S)?))?\z/
6
16
  MINUTES_IN_SECONDS = 60
7
17
  HOURS_IN_SECONDS = 60 * MINUTES_IN_SECONDS
8
18
  DAYS_IN_SECONDS = 24 * HOURS_IN_SECONDS
19
+ MAX_SECONDS = 36_500 * DAYS_IN_SECONDS
9
20
 
10
21
  def initialize(definition)
11
22
  @seconds = self.class.parse(definition)
12
23
  end
13
24
 
25
+ # Integer milliseconds for a valid duration string, or nil when +definition+
26
+ # is not a String or does not match the grammar.
27
+ def self.parse_millis(definition)
28
+ seconds = exact_seconds(definition)
29
+ return nil if seconds.nil?
30
+
31
+ (seconds * 1000).round(half: :up).to_i
32
+ end
33
+
34
+ def self.valid?(definition)
35
+ !exact_seconds(definition).nil?
36
+ end
37
+
38
+ # Seconds as a Float. Raises ArgumentError when +definition+ is not a valid
39
+ # duration (it used to return 0 for anything unparseable).
14
40
  def self.parse(definition)
41
+ seconds = exact_seconds(definition)
42
+ raise ArgumentError, "invalid ISO-8601 duration: #{definition.inspect}" if seconds.nil?
43
+
44
+ seconds.to_f
45
+ end
46
+
47
+ # Exact Rational seconds, or nil when invalid.
48
+ def self.exact_seconds(definition)
49
+ return nil unless definition.is_a?(String)
50
+
15
51
  match = PATTERN.match(definition)
16
- return 0 unless match
52
+ return nil if match.nil?
53
+ return nil if match[:days].nil? && match[:time].nil?
54
+ return nil if match[:time] && match[:time].empty?
55
+
56
+ total = (Integer(match[:days] || '0', 10) * DAYS_IN_SECONDS) +
57
+ (Integer(match[:hours] || '0', 10) * HOURS_IN_SECONDS) +
58
+ (Integer(match[:minutes] || '0', 10) * MINUTES_IN_SECONDS) +
59
+ decimal_seconds(match[:seconds])
60
+ return nil if total > MAX_SECONDS
61
+
62
+ total
63
+ end
64
+
65
+ def self.decimal_seconds(text)
66
+ return 0 if text.nil?
17
67
 
18
- days = match[:days]&.to_f || 0
19
- hours = match[:hours]&.to_f || 0
20
- minutes = match[:minutes]&.to_f || 0
21
- seconds = match[:seconds]&.to_f || 0
68
+ whole, frac = text.split('.', 2)
69
+ return Integer(whole, 10) if frac.nil?
22
70
 
23
- ((days * DAYS_IN_SECONDS) + (hours * HOURS_IN_SECONDS) + (minutes * MINUTES_IN_SECONDS) + seconds)
71
+ Integer(whole, 10) + Rational(Integer(frac, 10), 10**frac.length)
24
72
  end
73
+ private_class_method :decimal_seconds
25
74
 
26
75
  def in_seconds
27
76
  @seconds
@@ -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
@@ -541,8 +556,9 @@ module Quonfig
541
556
  when Numeric
542
557
  raw.to_i
543
558
  when String
544
- seconds = Quonfig::Duration.parse(raw)
545
- (seconds * 1000).round
559
+ # nil for a malformed value, so get() never returns the raw string or
560
+ # a made-up number; Client#get_duration applies the default.
561
+ Quonfig::Duration.parse_millis(raw)
546
562
  when Hash
547
563
  secs = (raw['seconds'] || raw[:seconds] || 0).to_f
548
564
  nanos = (raw['nanos'] || raw[:nanos] || 0).to_f
@@ -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
 
@@ -233,6 +274,8 @@ module Quonfig
233
274
  when 'string_list'
234
275
  env_value.split(/\s*,\s*/)
235
276
  when 'duration'
277
+ raise ArgumentError, 'invalid ISO-8601 duration' unless Quonfig::Duration.valid?(env_value)
278
+
236
279
  env_value
237
280
  else
238
281
  env_value
@@ -247,6 +290,9 @@ module Quonfig
247
290
  when 'double' then 'double'
248
291
  when 'bool' then 'bool'
249
292
  when 'string_list' then 'string_list'
293
+ # Keep 'duration' so Evaluator#unwrapped_value converts to ms and
294
+ # get() matches the stored path (qfg-2agi.22).
295
+ when 'duration' then 'duration'
250
296
  else 'string'
251
297
  end
252
298
  end
@@ -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.7.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.7.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-10-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport