toggly 0.5.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f5f956d153e09adf30a2d8b4ba1fb649322549cea28765622e62712f7d6be8e
4
- data.tar.gz: bbd76736bd3dc8158e0873cfda05b3ad628e78ee2e6202a5bdc61878fc5a7ae7
3
+ metadata.gz: af67c2cd1ab3f935ea72b5f3959e0958f8e7225eb8d176a40fbdb9baf3b3f57d
4
+ data.tar.gz: 8911b6582910f651d545a1fd625314f7a5aab4c8972188bc31844156ee37882e
5
5
  SHA512:
6
- metadata.gz: 4a436c80e94da6a310db84d5f36835de8fc79496cd9ed5c62734a79b0376852fd4dc5d0bd2672c1fc66270ddb21c260c857ab099c886cc487bedf94b99d307e0
7
- data.tar.gz: 5aa1eed6b7e38353c83b5693d0d71063f47515b50130dee824ccaf7a0d8b932de981f2559e352aca381f5a193ba6515100a1320290b5aba987ff6fdfa59c0cc7
6
+ metadata.gz: 78ff528d676027bb0379688179b888fae05c567f0a1f57b78982890e07fc3c68cf68c763f0ddd63c8611e21c2aea4bcc0e25d31dde2f33e6ba11adbb52653cf4
7
+ data.tar.gz: dbe5aad2ad173a579bd7385a24b00a18980be769ea89a46e875ec6ab31d5e5af4246a9ac74789530cc7bf673dd237addee389261e2c5fc515d3989fe520b5653
data/CHANGELOG.md CHANGED
@@ -5,6 +5,64 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.0.0] - 2026-09-23
9
+
10
+ ### Changed (Breaking)
11
+
12
+ - **Catalog-local, MF-parity feature variants**, replacing the
13
+ `enable_variants` / `evaluated-variants-signed` dual-rail from 0.6.0.
14
+ `Client#get_variant` / `#get_variant_value` now assign variants **locally**
15
+ from the same `variants` / `allocation` payload on the `definitions` /
16
+ `definitions-signed` catalog that drives `enabled?` — there is no separate
17
+ network call. Assignment matches `Microsoft.FeatureManagement` 4.7.0's
18
+ `IVariantFeatureManager` bit-for-bit (user → group → percentile → default,
19
+ `StatusOverride`, percentile SHA-256 hashing) and is verified against the
20
+ shared `variant-allocator-corpus/cases.json` gold corpus (100% pass).
21
+ - `get_variant(feature_key, context: nil)` / `get_variant_value(feature_key,
22
+ context: nil)` now take the same `context:` (`userId` + `groups`) used by
23
+ `enabled?`, instead of a client-wide `variant_identity`.
24
+ - `VariantResult` gains `enabled` (effective enabled after the assigned
25
+ variant's `StatusOverride` — `enabled?` itself stays filter-based only)
26
+ and `reason` (`"User"` | `"Group"` | `"Percentile"` |
27
+ `"DefaultWhenEnabled"` | `"DefaultWhenDisabled"`).
28
+ - New `FeatureDefinition#variants` / `#allocation` (`FeatureVariant`,
29
+ `FeatureVariantAllocation`), parsed from the `definitions` wire.
30
+ - **Removed**: `Config#enable_variants` / `#variant_identity` /
31
+ `#variant_groups` / `#variant_claims` / `#variants_endpoint`,
32
+ `Client#variant_defs` / `#set_variant_identity`, `EvaluatedVariantDef`,
33
+ and the `evaluated-variants-signed` fetch/cache rail (including the
34
+ `SnapshotProviders` `save_variants` / `load_variants` hooks — variants are
35
+ now part of the ordinary definitions snapshot via `FeatureDefinition`).
36
+
37
+ **Migration**: drop `enable_variants` / `variant_identity` /
38
+ `set_variant_identity` from your config. Pass targeting via
39
+ `get_variant(key, context: Toggly::Context.new(identity: ..., groups: ...))`.
40
+
41
+ ## [0.6.0] - 2026-09-22
42
+
43
+ ### Added
44
+
45
+ - Server-evaluated feature variants (dual-rail). Set `enable_variants: true`
46
+ on `Config` to additionally fetch `evaluated-variants-signed/{app_key}/
47
+ {environment}` on its own rail alongside the existing `definitions` /
48
+ `definitions-signed` pipeline. `definitions` / `definitions-signed` remain
49
+ the sole source of truth for `enabled?` regardless of `enable_variants` —
50
+ evaluated variants are additive and only feed `get_variant` /
51
+ `get_variant_value`; they never override `enabled?`. New `Config` options:
52
+ `enable_variants`, `variant_identity`, `variant_groups`, `variant_claims`.
53
+ New `Client#get_variant` / `Client#get_variant_value` return the assigned
54
+ variant name and `configuration_value`, or `nil` when unassigned or
55
+ disabled. New `Client#set_variant_identity` updates the `userId` sent to
56
+ `evaluated-variants-signed` and refreshes.
57
+ - `SnapshotProviders::Base#save_variants` / `#load_variants` (default no-op)
58
+ so `Memory` and `File` providers persist evaluated variants across
59
+ restarts, independent of the `definitions` snapshot.
60
+
61
+ **Note:** the new `get_variant` assignment is unrelated to the existing
62
+ `variant:` label on `record_usage` / `record_view`, which is a free-form
63
+ usage tag (defaults to `"enabled"`/`"disabled"`) and does not reflect
64
+ `evaluated-variants-signed` results.
65
+
8
66
  ## [0.5.2] - 2026-09-17
9
67
 
10
68
  ### Fixed
data/README.md CHANGED
@@ -38,6 +38,28 @@ if client.enabled?(:my_feature)
38
38
  end
39
39
  ```
40
40
 
41
+ ## Feature variants (catalog-local, MF-parity)
42
+
43
+ `get_variant` / `get_variant_value` assign a variant purely from the
44
+ feature's `variants` / `allocation` catalog data (no network round-trip),
45
+ matching `Microsoft.FeatureManagement`'s `IVariantFeatureManager`
46
+ bit-for-bit — verified against the shared
47
+ `variant-allocator-corpus/cases.json` gold corpus.
48
+
49
+ ```ruby
50
+ variant = client.get_variant('checkout-flow', context: Toggly::Context.new(identity: 'user-1'))
51
+ variant&.name # assigned variant name, or nil
52
+ variant&.configuration_value # untyped configuration payload
53
+ variant&.enabled # effective enabled after StatusOverride
54
+ variant&.reason # "User" | "Group" | "Percentile" | "DefaultWhenEnabled" | "DefaultWhenDisabled"
55
+
56
+ client.get_variant_value('checkout-flow', context: context) # shortcut for configuration_value
57
+ ```
58
+
59
+ `enabled?` stays filter-based only. The `enable_variants` /
60
+ `evaluated-variants-signed` dual-rail (and `set_variant_identity`) was
61
+ removed in 1.0 — see [CHANGELOG.md](CHANGELOG.md).
62
+
41
63
  ## Usage & metrics telemetry
42
64
 
43
65
  When `app_key` is set, usage tracking and metrics default **on** (disable with
@@ -2,30 +2,23 @@
2
2
 
3
3
  module Toggly
4
4
  class Client
5
- # Durable snapshot load/save helpers for Client.
5
+ # Durable snapshot load/save helpers for Client. Definitions (including
6
+ # `variants` / `allocation`) are the only persisted rail — there is no
7
+ # separate evaluated-variants snapshot (removed with the dual-rail).
6
8
  module SnapshotSupport
7
9
  private
8
10
 
9
- # @return [Boolean] true when a durable snapshot was applied into memory
11
+ # @return [Boolean] true when the durable snapshot was applied into memory
10
12
  def load_snapshot
11
13
  return false unless @config.snapshot_provider
12
14
 
13
- data = @config.snapshot_provider.load
14
- return false unless data
15
-
16
- @mutex.synchronize do
17
- @definitions = data[:definitions]
18
- @definitions_loaded = true
19
- end
20
-
21
- log_debug("Loaded #{@definitions.size} features from snapshot")
22
- true
15
+ load_definitions_snapshot
23
16
  rescue StandardError => e
24
17
  log_warn("Failed to load snapshot: #{e.message}")
25
18
  false
26
19
  end
27
20
 
28
- def save_snapshot
21
+ def save_definitions_snapshot
29
22
  return unless @config.snapshot_provider
30
23
 
31
24
  @config.snapshot_provider.save(@definitions)
@@ -33,6 +26,19 @@ module Toggly
33
26
  rescue StandardError => e
34
27
  log_warn("Failed to save snapshot: #{e.message}")
35
28
  end
29
+
30
+ def load_definitions_snapshot
31
+ data = @config.snapshot_provider.load
32
+ return false unless data
33
+
34
+ @mutex.synchronize do
35
+ @definitions = data[:definitions]
36
+ @definitions_loaded = true
37
+ end
38
+
39
+ log_debug("Loaded #{@definitions.size} features from snapshot")
40
+ true
41
+ end
36
42
  end
37
43
  end
38
44
  end
data/lib/toggly/client.rb CHANGED
@@ -80,7 +80,9 @@ module Toggly
80
80
 
81
81
  definition = @mutex.synchronize { @definitions[key] }
82
82
 
83
- # Check defaults if not found
83
+ # `enabled?` stays filter-based only (definitions/definitions-signed).
84
+ # It never reflects a variant's StatusOverride — see `get_variant`
85
+ # (`VariantResult#enabled`) for MF-identical effective-enabled semantics.
84
86
  result = if definition.nil?
85
87
  if !default.nil?
86
88
  default
@@ -109,6 +111,54 @@ module Toggly
109
111
  !enabled?(feature_key, context: context, default: default.nil? ? nil : !default)
110
112
  end
111
113
 
114
+ # Get the assigned variant for a feature, computed locally from the
115
+ # feature's catalog `variants` / `allocation` (MF-parity, bit-for-bit
116
+ # with `Microsoft.FeatureManagement`'s `IVariantFeatureManager`). Returns
117
+ # nil when the feature is unknown, has no variants configured, or no
118
+ # variant resolves for this context (see {VariantAllocator}).
119
+ #
120
+ # There is no network round-trip here — this superseded the
121
+ # `enable_variants` / `evaluated-variants-signed` dual-rail removed in
122
+ # 1.0 (see CHANGELOG).
123
+ #
124
+ # NOTE: this is the actual A/B assignment. It is unrelated to the
125
+ # `variant:` telemetry label on `record_usage` / `record_view`, which is
126
+ # a free-form usage tag (defaults to "enabled"/"disabled").
127
+ #
128
+ # @param feature_key [String, Symbol] The feature key
129
+ # @param context [Context, nil] Optional targeting context (userId + groups)
130
+ # @return [VariantResult, nil]
131
+ def get_variant(feature_key, context: nil)
132
+ key = feature_key.to_s
133
+ definition = @mutex.synchronize { @definitions[key] }
134
+ return nil if definition.nil?
135
+
136
+ enabled = @engine.evaluate(definition, context)
137
+ assignment = VariantAllocator.assign(
138
+ definition,
139
+ enabled: enabled,
140
+ identity: context&.identity,
141
+ groups: context&.groups || []
142
+ )
143
+ return nil if assignment.variant_name.nil?
144
+
145
+ VariantResult.new(
146
+ name: assignment.variant_name,
147
+ configuration_value: assignment.configuration_value,
148
+ enabled: assignment.enabled,
149
+ reason: assignment.reason
150
+ )
151
+ end
152
+
153
+ # Get the configuration value for the assigned variant, if any.
154
+ #
155
+ # @param feature_key [String, Symbol] The feature key
156
+ # @param context [Context, nil] Optional targeting context (userId + groups)
157
+ # @return [Object, nil]
158
+ def get_variant_value(feature_key, context: nil)
159
+ get_variant(feature_key, context: context)&.configuration_value
160
+ end
161
+
112
162
  # Get detailed evaluation result
113
163
  #
114
164
  # @param feature_key [String, Symbol] The feature key
@@ -161,27 +211,7 @@ module Toggly
161
211
  end
162
212
 
163
213
  begin
164
- result = @provider.fetch(force: force)
165
- record_refresh_cache_outcome(result.cache_outcome)
166
-
167
- if result.definitions
168
- @mutex.synchronize do
169
- @definitions = result.definitions
170
- @definitions_loaded = true
171
- @ready = true
172
- end
173
-
174
- save_snapshot
175
- log_info("Definitions refreshed (#{result.definitions.size} features)")
176
- true
177
- else
178
- false
179
- end
180
- rescue StandardError => e
181
- log_error("Failed to refresh definitions: #{e.message}")
182
- # Network error / timeout keeping last-good revision (incl. empty) — hit.
183
- record_definition_cache_hit if definitions_cached?
184
- false
214
+ refresh_definitions_rail(force: force)
185
215
  ensure
186
216
  drain_pending = false
187
217
  @mutex.synchronize do
@@ -290,6 +320,37 @@ module Toggly
290
320
 
291
321
  private
292
322
 
323
+ # `definitions` / `definitions-signed` → local rule eval. The sole
324
+ # source of truth for `enabled?`; also carries `variants` / `allocation`
325
+ # for the catalog-local `get_variant` assignment.
326
+ def refresh_definitions_rail(force:)
327
+ refresh_definitions(force: force)
328
+ rescue StandardError => e
329
+ log_error("Failed to refresh definitions: #{e.message}")
330
+ # Network error / timeout keeping last-good revision (incl. empty) — hit.
331
+ record_definition_cache_hit if definitions_cached?
332
+ false
333
+ end
334
+
335
+ def refresh_definitions(force:)
336
+ result = @provider.fetch(force: force)
337
+ record_refresh_cache_outcome(result.cache_outcome)
338
+
339
+ if result.definitions
340
+ @mutex.synchronize do
341
+ @definitions = result.definitions
342
+ @definitions_loaded = true
343
+ @ready = true
344
+ end
345
+
346
+ save_definitions_snapshot
347
+ log_info("Definitions refreshed (#{result.definitions.size} features)")
348
+ true
349
+ else
350
+ false
351
+ end
352
+ end
353
+
293
354
  def initialize_definitions
294
355
  # Startup served from durable snapshot before first network — cache hit.
295
356
  # Distinct from the subsequent refresh() network outcome (no double-count
@@ -195,7 +195,7 @@ module Toggly
195
195
  when :new_content
196
196
  handle_new_content(response, response_etag, response_lm)
197
197
  when :error_status
198
- handle_error_status(status, response)
198
+ handle_error_status(status, response, resource: "definitions")
199
199
  end
200
200
  end
201
201
 
@@ -216,12 +216,12 @@ module Toggly
216
216
  raise DefinitionsError, "Failed to parse definitions: #{e.message}"
217
217
  end
218
218
 
219
- def handle_error_status(status, response)
219
+ def handle_error_status(status, response, resource: "definitions")
220
220
  case status
221
221
  when 401, 403
222
222
  raise DefinitionsError, "Authentication failed: #{status}"
223
223
  when 404
224
- raise DefinitionsError, "Definitions not found (check app_key and environment)"
224
+ raise DefinitionsError, "#{resource.capitalize} not found (check app_key and environment)"
225
225
  else
226
226
  raise NetworkError.new(
227
227
  "API error: #{status}",
@@ -35,6 +35,12 @@ module Toggly
35
35
  # @return [String, nil] Feature description
36
36
  attr_reader :description
37
37
 
38
+ # @return [Array<FeatureVariant>] Named variants for MF-parity assignment
39
+ attr_reader :variants
40
+
41
+ # @return [FeatureVariantAllocation, nil] Allocation rules for variant assignment
42
+ attr_reader :allocation
43
+
38
44
  # Feature types
39
45
  TYPES = %w[Release Experiment Ops Permission].freeze
40
46
 
@@ -50,7 +56,9 @@ module Toggly
50
56
  updated_at: nil,
51
57
  requirement_type: "Any",
52
58
  context_kind: nil,
53
- context_requirement_type: nil
59
+ context_requirement_type: nil,
60
+ variants: [],
61
+ allocation: nil
54
62
  )
55
63
  @feature_key = feature_key.to_s
56
64
  @feature_type = validate_type(feature_type)
@@ -63,6 +71,8 @@ module Toggly
63
71
  @requirement_type = requirement_type || "Any"
64
72
  @context_kind = context_kind
65
73
  @context_requirement_type = context_requirement_type
74
+ @variants = Array(variants)
75
+ @allocation = allocation
66
76
  end
67
77
 
68
78
  # Create from a hash (e.g., from JSON)
@@ -83,6 +93,9 @@ module Toggly
83
93
  !rules.empty?
84
94
  end
85
95
 
96
+ variants = Array(hash[:variants]).map { |v| FeatureVariant.from_hash(v) }
97
+ allocation = FeatureVariantAllocation.from_hash(hash[:allocation])
98
+
86
99
  new(
87
100
  feature_key: hash[:featureKey] || hash[:feature_key],
88
101
  feature_type: hash[:featureType] || hash[:feature_type] || "Release",
@@ -94,7 +107,9 @@ module Toggly
94
107
  updated_at: hash[:updatedAt] || hash[:updated_at],
95
108
  requirement_type: hash[:requirementType] || hash[:requirement_type] || "Any",
96
109
  context_kind: hash[:contextKind] || hash[:context_kind],
97
- context_requirement_type: hash[:contextRequirementType] || hash[:context_requirement_type]
110
+ context_requirement_type: hash[:contextRequirementType] || hash[:context_requirement_type],
111
+ variants: variants,
112
+ allocation: allocation
98
113
  )
99
114
  end
100
115
 
@@ -110,7 +125,9 @@ module Toggly
110
125
  metadata: @metadata,
111
126
  description: @description,
112
127
  created_at: @created_at&.iso8601,
113
- updated_at: @updated_at&.iso8601
128
+ updated_at: @updated_at&.iso8601,
129
+ variants: @variants.map(&:to_h),
130
+ allocation: @allocation&.to_h
114
131
  }
115
132
  end
116
133
 
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ # A single named variant of a feature flag, as sent on the catalog
5
+ # (`definitions` / `definitions-signed`) wire alongside `filters`.
6
+ #
7
+ # Matches the shape produced by `Microsoft.FeatureManagement`'s
8
+ # `VariantDefinition` (`Name`, `ConfigurationValue`, `StatusOverride`).
9
+ class FeatureVariant
10
+ # Valid `StatusOverride` values (MF `StatusOverride` enum as strings).
11
+ STATUS_OVERRIDES = %w[None Enabled Disabled].freeze
12
+
13
+ # @return [String] Variant name
14
+ attr_reader :name
15
+
16
+ # @return [Object, nil] Untyped configuration payload for this variant
17
+ attr_reader :configuration_value
18
+
19
+ # @return [String] "None" | "Enabled" | "Disabled"
20
+ attr_reader :status_override
21
+
22
+ def initialize(name:, configuration_value: nil, status_override: "None")
23
+ @name = name.to_s
24
+ @configuration_value = configuration_value
25
+ @status_override = STATUS_OVERRIDES.include?(status_override.to_s) ? status_override.to_s : "None"
26
+ end
27
+
28
+ # @param hash [Hash] Variant hash (camelCase wire or snake_case snapshot)
29
+ # @return [FeatureVariant]
30
+ def self.from_hash(hash)
31
+ hash = symbolize_keys(hash)
32
+ configuration_value = if hash.key?(:configurationValue)
33
+ hash[:configurationValue]
34
+ else
35
+ hash[:configuration_value]
36
+ end
37
+
38
+ new(
39
+ name: hash[:name],
40
+ configuration_value: configuration_value,
41
+ status_override: hash[:statusOverride] || hash[:status_override] || "None"
42
+ )
43
+ end
44
+
45
+ # @return [Boolean] True when this variant forces the feature to be
46
+ # reported enabled regardless of the base filter-evaluated state.
47
+ def enabled_override?
48
+ @status_override == "Enabled"
49
+ end
50
+
51
+ # @return [Boolean] True when this variant forces the feature to be
52
+ # reported disabled regardless of the base filter-evaluated state.
53
+ def disabled_override?
54
+ @status_override == "Disabled"
55
+ end
56
+
57
+ # @return [Hash]
58
+ def to_h
59
+ {
60
+ name: @name,
61
+ configuration_value: @configuration_value,
62
+ status_override: @status_override
63
+ }
64
+ end
65
+
66
+ def ==(other)
67
+ other.is_a?(FeatureVariant) &&
68
+ @name == other.name &&
69
+ @configuration_value == other.configuration_value &&
70
+ @status_override == other.status_override
71
+ end
72
+ alias eql? ==
73
+
74
+ def hash
75
+ [@name, @configuration_value, @status_override].hash
76
+ end
77
+
78
+ def self.symbolize_keys(hash)
79
+ return {} unless hash.is_a?(Hash)
80
+
81
+ hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
82
+ end
83
+ private_class_method :symbolize_keys
84
+ end
85
+
86
+ # Allocation rules for assigning variants to users/groups/percentile
87
+ # buckets, matching `Microsoft.FeatureManagement`'s `Allocation` schema.
88
+ class FeatureVariantAllocation
89
+ # @return [String, nil] Variant assigned when the feature is enabled and
90
+ # no user/group/percentile allocation matched.
91
+ attr_reader :default_when_enabled
92
+
93
+ # @return [String, nil] Variant assigned when the feature is disabled.
94
+ attr_reader :default_when_disabled
95
+
96
+ # @return [String, nil] Seed for the percentile hash; falls back to
97
+ # "allocation\n{featureName}" when nil.
98
+ attr_reader :seed
99
+
100
+ # @return [Array<Hash>] `[{ variant:, users: [...] }, ...]`
101
+ attr_reader :user
102
+
103
+ # @return [Array<Hash>] `[{ variant:, groups: [...] }, ...]`
104
+ attr_reader :group
105
+
106
+ # @return [Array<Hash>] `[{ variant:, from:, to: }, ...]`
107
+ attr_reader :percentile
108
+
109
+ def initialize(default_when_enabled: nil, default_when_disabled: nil, seed: nil, user: [], group: [], percentile: [])
110
+ @default_when_enabled = default_when_enabled
111
+ @default_when_disabled = default_when_disabled
112
+ @seed = seed
113
+ @user = Array(user)
114
+ @group = Array(group)
115
+ @percentile = Array(percentile)
116
+ end
117
+
118
+ # @param hash [Hash, nil]
119
+ # @return [FeatureVariantAllocation, nil] nil when +hash+ is nil (no allocation configured)
120
+ def self.from_hash(hash)
121
+ return nil unless hash.is_a?(Hash)
122
+
123
+ hash = symbolize_keys(hash)
124
+ new(
125
+ default_when_enabled: hash[:defaultWhenEnabled] || hash[:default_when_enabled],
126
+ default_when_disabled: hash[:defaultWhenDisabled] || hash[:default_when_disabled],
127
+ seed: hash[:seed],
128
+ user: parse_entries(hash[:user], :users),
129
+ group: parse_entries(hash[:group], :groups),
130
+ percentile: parse_percentile_entries(hash[:percentile])
131
+ )
132
+ end
133
+
134
+ # @return [Hash]
135
+ def to_h
136
+ {
137
+ default_when_enabled: @default_when_enabled,
138
+ default_when_disabled: @default_when_disabled,
139
+ seed: @seed,
140
+ user: @user,
141
+ group: @group,
142
+ percentile: @percentile
143
+ }
144
+ end
145
+
146
+ def ==(other)
147
+ other.is_a?(FeatureVariantAllocation) &&
148
+ @default_when_enabled == other.default_when_enabled &&
149
+ @default_when_disabled == other.default_when_disabled &&
150
+ @seed == other.seed &&
151
+ @user == other.user &&
152
+ @group == other.group &&
153
+ @percentile == other.percentile
154
+ end
155
+ alias eql? ==
156
+
157
+ def hash
158
+ [@default_when_enabled, @default_when_disabled, @seed, @user, @group, @percentile].hash
159
+ end
160
+
161
+ def self.parse_entries(raw, members_key)
162
+ Array(raw).filter_map do |entry|
163
+ next unless entry.is_a?(Hash)
164
+
165
+ entry = symbolize_keys(entry)
166
+ { variant: entry[:variant], members_key => Array(entry[members_key]).map(&:to_s) }
167
+ end
168
+ end
169
+ private_class_method :parse_entries
170
+
171
+ def self.parse_percentile_entries(raw)
172
+ Array(raw).filter_map do |entry|
173
+ next unless entry.is_a?(Hash)
174
+
175
+ entry = symbolize_keys(entry)
176
+ { variant: entry[:variant], from: entry[:from].to_f, to: entry[:to].to_f }
177
+ end
178
+ end
179
+ private_class_method :parse_percentile_entries
180
+
181
+ def self.symbolize_keys(hash)
182
+ return {} unless hash.is_a?(Hash)
183
+
184
+ hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
185
+ end
186
+ private_class_method :symbolize_keys
187
+ end
188
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Toggly
6
+ # Catalog-local, MF-parity variant allocator.
7
+ #
8
+ # Assigns a feature variant purely from a {FeatureDefinition}'s
9
+ # `variants` / `allocation` and a targeting context (`identity` + `groups`),
10
+ # bit-for-bit matching `Microsoft.FeatureManagement` 4.7.0's
11
+ # `FeatureManager#GetVariantAsync`. Verified against the shared
12
+ # `variant-allocator-corpus/cases.json` gold corpus.
13
+ #
14
+ # This replaces the `evaluated-variants-signed` dual-rail: there is no
15
+ # network call here — the caller supplies the already filter-evaluated
16
+ # `enabled` boolean (the same value `Client#enabled?` would return for
17
+ # this feature/context) and this module does the rest locally.
18
+ module VariantAllocator
19
+ module_function
20
+
21
+ # Assignment reasons (mirrors MF `AssignmentReason`).
22
+ REASON_NONE = "None"
23
+ REASON_USER = "User"
24
+ REASON_GROUP = "Group"
25
+ REASON_PERCENTILE = "Percentile"
26
+ REASON_DEFAULT_WHEN_ENABLED = "DefaultWhenEnabled"
27
+ REASON_DEFAULT_WHEN_DISABLED = "DefaultWhenDisabled"
28
+
29
+ # Result of a variant assignment.
30
+ #
31
+ # @!attribute variant_name [String, nil] Assigned variant name, or nil if unassigned
32
+ # @!attribute configuration_value [Object, nil] The assigned variant's configuration payload
33
+ # @!attribute enabled [Boolean] Effective enabled flag after StatusOverride is applied
34
+ # @!attribute reason [String] One of the REASON_* constants above
35
+ Assignment = Struct.new(:variant_name, :configuration_value, :enabled, :reason, keyword_init: true)
36
+
37
+ # Assign a variant for a feature + targeting context.
38
+ #
39
+ # @param definition [FeatureDefinition] Feature definition (variants + allocation)
40
+ # @param enabled [Boolean] The filter-evaluated enabled state for this context
41
+ # (i.e. what `EvaluationEngine#evaluate` returns for this definition/context)
42
+ # @param identity [String, nil] Targeting user id
43
+ # @param groups [Array<String>] Targeting groups
44
+ # @param ignore_case [Boolean] MF `TargetingEvaluationOptions.IgnoreCase` parity knob.
45
+ # Defaults to `false`, matching Microsoft.FeatureManagement's default.
46
+ # @return [Assignment]
47
+ def assign(definition, enabled:, identity: nil, groups: [], ignore_case: false)
48
+ variants = definition&.variants || []
49
+ allocation = definition&.allocation
50
+
51
+ return build_assignment(nil, variants, enabled, REASON_NONE) if variants.empty?
52
+
53
+ unless enabled
54
+ variant_name = allocation&.default_when_disabled
55
+ return build_assignment(variant_name, variants, enabled, REASON_DEFAULT_WHEN_DISABLED)
56
+ end
57
+
58
+ variant_name, reason = resolve_enabled_allocation(
59
+ allocation, identity, groups, ignore_case, definition.feature_key
60
+ )
61
+ build_assignment(variant_name, variants, enabled, reason)
62
+ end
63
+
64
+ def resolve_enabled_allocation(allocation, identity, groups, ignore_case, feature_key)
65
+ return [nil, REASON_DEFAULT_WHEN_ENABLED] unless allocation
66
+
67
+ user_variant = match_user(allocation.user, identity, ignore_case)
68
+ return [user_variant, REASON_USER] if user_variant
69
+
70
+ group_variant = match_group(allocation.group, groups, ignore_case)
71
+ return [group_variant, REASON_GROUP] if group_variant
72
+
73
+ percentile_variant = match_percentile(allocation.percentile, allocation.seed, identity, feature_key, ignore_case)
74
+ return [percentile_variant, REASON_PERCENTILE] if percentile_variant
75
+
76
+ [allocation.default_when_enabled, REASON_DEFAULT_WHEN_ENABLED]
77
+ end
78
+
79
+ def match_user(entries, identity, ignore_case)
80
+ return nil if identity.nil? || identity.to_s.empty?
81
+
82
+ Array(entries).each do |entry|
83
+ users = Array(entry[:users])
84
+ matched = if ignore_case
85
+ users.any? { |u| u.to_s.casecmp?(identity.to_s) }
86
+ else
87
+ users.include?(identity.to_s)
88
+ end
89
+ return entry[:variant] if matched
90
+ end
91
+ nil
92
+ end
93
+
94
+ def match_group(entries, groups, ignore_case)
95
+ context_groups = Array(groups).map(&:to_s)
96
+ return nil if context_groups.empty?
97
+
98
+ Array(entries).each do |entry|
99
+ entry_groups = Array(entry[:groups]).map(&:to_s)
100
+ matched = if ignore_case
101
+ entry_groups.any? { |eg| context_groups.any? { |cg| cg.casecmp?(eg) } }
102
+ else
103
+ entry_groups.intersect?(context_groups)
104
+ end
105
+ return entry[:variant] if matched
106
+ end
107
+ nil
108
+ end
109
+
110
+ def match_percentile(entries, seed, identity, feature_key, ignore_case)
111
+ list = Array(entries)
112
+ return nil if list.empty?
113
+
114
+ pct = compute_percentile(identity, seed, feature_key, ignore_case)
115
+ list.each do |entry|
116
+ from = entry[:from].to_f
117
+ to = entry[:to].to_f
118
+ matched = to >= 100.0 ? pct >= from : (pct >= from && pct < to)
119
+ return entry[:variant] if matched
120
+ end
121
+ nil
122
+ end
123
+
124
+ # MF-parity percentile hash.
125
+ #
126
+ # `contextId = "{userId}\n{hint}"`, `hint = seed` (if non-nil) else
127
+ # `"allocation\n{featureName}"`. SHA-256 over the UTF-8 bytes; the first
128
+ # 4 bytes read as a little-endian uint32; `pct = marker / 0xFFFFFFFF * 100`.
129
+ #
130
+ # @return [Float] Percentile in [0, 100]
131
+ def compute_percentile(identity, seed, feature_key, ignore_case)
132
+ user_id = identity.to_s
133
+ user_id = user_id.downcase if ignore_case
134
+ hint = seed.nil? ? "allocation\n#{feature_key}" : seed.to_s
135
+ context_id = "#{user_id}\n#{hint}"
136
+
137
+ digest = Digest::SHA256.digest(context_id)
138
+ marker = digest.byteslice(0, 4).unpack1("V") # little-endian uint32; unpack1("V") is endian-explicit
139
+ (marker.to_f / 0xFFFFFFFF) * 100.0
140
+ end
141
+
142
+ def build_assignment(variant_name, variants, base_enabled, reason)
143
+ variant = Array(variants).find { |v| v.name == variant_name }
144
+ effective_enabled =
145
+ if variant&.enabled_override?
146
+ true
147
+ elsif variant&.disabled_override?
148
+ false
149
+ else
150
+ base_enabled
151
+ end
152
+
153
+ Assignment.new(
154
+ variant_name: variant_name,
155
+ configuration_value: variant&.configuration_value,
156
+ enabled: effective_enabled,
157
+ reason: reason
158
+ )
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ # Assigned variant for a feature, returned by `Client#get_variant`.
5
+ #
6
+ # Assignment is computed locally from the feature's catalog
7
+ # `variants` / `allocation` (see {VariantAllocator}), matching
8
+ # `Microsoft.FeatureManagement`'s `IVariantFeatureManager` bit-for-bit.
9
+ # There is no network round-trip: this is not the `enable_variants`
10
+ # dual-rail from earlier releases (removed; see CHANGELOG).
11
+ class VariantResult
12
+ # @return [String] Variant name
13
+ attr_reader :name
14
+
15
+ # @return [Object, nil] Untyped configuration payload for this variant
16
+ attr_reader :configuration_value
17
+
18
+ # @return [Boolean] Effective enabled flag for this feature/context after
19
+ # the variant's `StatusOverride` is applied. This can differ from
20
+ # `Client#enabled?`, which stays filter-based only; use this field when
21
+ # you need MF-identical `GetVariantAsync`-style effective-enabled
22
+ # semantics (e.g. a variant with `StatusOverride: "Enabled"` on an
23
+ # otherwise-disabled feature).
24
+ attr_reader :enabled
25
+
26
+ # @return [String] Assignment reason: "User" | "Group" | "Percentile" |
27
+ # "DefaultWhenEnabled" | "DefaultWhenDisabled"
28
+ attr_reader :reason
29
+
30
+ def initialize(name:, configuration_value: nil, enabled: true, reason: nil)
31
+ @name = name
32
+ @configuration_value = configuration_value
33
+ @enabled = enabled
34
+ @reason = reason
35
+ end
36
+
37
+ # @return [Hash]
38
+ def to_h
39
+ { name: @name, configuration_value: @configuration_value, enabled: @enabled, reason: @reason }
40
+ end
41
+
42
+ def ==(other)
43
+ other.is_a?(VariantResult) &&
44
+ @name == other.name &&
45
+ @configuration_value == other.configuration_value &&
46
+ @enabled == other.enabled &&
47
+ @reason == other.reason
48
+ end
49
+ alias eql? ==
50
+ end
51
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toggly
4
- VERSION = "0.5.2"
4
+ VERSION = "1.0.0"
5
5
  end
data/lib/toggly.rb CHANGED
@@ -9,7 +9,10 @@ require_relative "toggly/sticky_hash"
9
9
  require_relative "toggly/user_agent_parser"
10
10
  require_relative "toggly/context"
11
11
  require_relative "toggly/errors"
12
+ require_relative "toggly/feature_variant"
12
13
  require_relative "toggly/feature_definition"
14
+ require_relative "toggly/variant_allocator"
15
+ require_relative "toggly/variant_result"
13
16
  require_relative "toggly/evaluators/base"
14
17
  require_relative "toggly/evaluators/segment_helpers"
15
18
  require_relative "toggly/evaluators/always_on"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toggly
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ops.ai
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-18 00:00:00.000000000 Z
11
+ date: 2026-09-23 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: High-performance Ruby SDK for Toggly feature flag management. Works with
14
14
  or without Toggly.io.
@@ -48,6 +48,7 @@ files:
48
48
  - lib/toggly/evaluators/time_window.rb
49
49
  - lib/toggly/evaluators/user_claims.rb
50
50
  - lib/toggly/feature_definition.rb
51
+ - lib/toggly/feature_variant.rb
51
52
  - lib/toggly/http_request_mapper.rb
52
53
  - lib/toggly/registry.rb
53
54
  - lib/toggly/request_context.rb
@@ -66,6 +67,8 @@ files:
66
67
  - lib/toggly/telemetry/runtime_flush.rb
67
68
  - lib/toggly/telemetry/usage_batcher.rb
68
69
  - lib/toggly/user_agent_parser.rb
70
+ - lib/toggly/variant_allocator.rb
71
+ - lib/toggly/variant_result.rb
69
72
  - lib/toggly/version.rb
70
73
  - proto/metrics.proto
71
74
  - proto/usage.proto