openfeature-sdk 0.7.0 → 0.8.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: 1b8b6e8a5fd01205278fa44c809eb3c1ef578b56e5542aa94793a090e605458d
4
- data.tar.gz: f85c7d0e8b804fb651e02cc3c59d14c21458f9fdb419d58df6d8fbb714a35b9b
3
+ metadata.gz: a637edf0b0a85dbfbaf655066912a64a21c1cd4a467b4fe557208c10184df3a9
4
+ data.tar.gz: 6fa0f3f6e534279a8e1b6296e1e8b524decd315c99e4bc6238575f3867a8a428
5
5
  SHA512:
6
- metadata.gz: a7fef199498fb02863ed74cf76a6fbff8acf65df23e2e5d4baad90b6ed3da589b6ca98da1540443a7367456a527819eb2380979aa4f1a1ef8780c33ccf425d32
7
- data.tar.gz: 71db2db0d85592861ebd5f117d7599e9d54374deb9a6eae5060716044abddb27a4bd10db7ca07314540a36adeb7c366afa1a8c21fb4f19824e05e15fd59a1a27
6
+ metadata.gz: d16e851abe52d3bbbac68aa9180a9c1ace7b7cf97ce3508c21e083d7780825b862a608a7f438598d6bcb44c50efeceb84148c2f5351d544cb5584546d2cb2c22
7
+ data.tar.gz: 18c44f236a822a02469a4ed66b297c607e7e59d440428266790ce1a95da21a549e122c605d7d1f9c70af9442d8f6ce8c0c7ff0242e1f69268ce242bb7dbb02b8
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.7.0"
2
+ ".": "0.8.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0](https://github.com/open-feature/ruby-sdk/compare/v0.7.1...v0.8.0) (2026-10-23)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * make hook inputs immutable ([#357](https://github.com/open-feature/ruby-sdk/issues/357))
9
+
10
+ ### Bug Fixes
11
+
12
+ * do not mutate the provider's ResolutionDetails ([#358](https://github.com/open-feature/ruby-sdk/issues/358)) ([ec657a5](https://github.com/open-feature/ruby-sdk/commit/ec657a5fc7b1514828ea2fa1cb6bfe017029f5af))
13
+ * make hook inputs immutable ([#357](https://github.com/open-feature/ruby-sdk/issues/357)) ([9e166fe](https://github.com/open-feature/ruby-sdk/commit/9e166fe09ec2a10d220295b600141cb351516b24))
14
+
15
+ ## [0.7.1](https://github.com/open-feature/ruby-sdk/compare/v0.7.0...v0.7.1) (2026-10-23)
16
+
17
+
18
+ ### Features
19
+
20
+ * isolated API instances ([#354](https://github.com/open-feature/ruby-sdk/issues/354)) ([1c92b4a](https://github.com/open-feature/ruby-sdk/commit/1c92b4ad7550a6b345e7471f84cd5a7ff445591e))
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * key provider state by identity, default the NoOp reason, freeze client metadata ([#355](https://github.com/open-feature/ruby-sdk/issues/355)) ([d15bd84](https://github.com/open-feature/ruby-sdk/commit/d15bd84e1456b6200e29c12c3463c4f8ddfe693f))
26
+
3
27
  ## [0.7.0](https://github.com/open-feature/ruby-sdk/compare/v0.6.7...v0.7.0) (2026-10-23)
4
28
 
5
29
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- openfeature-sdk (0.7.0)
4
+ openfeature-sdk (0.8.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -17,8 +17,8 @@
17
17
  </a>
18
18
  <!-- x-release-please-start-version -->
19
19
 
20
- <a href="https://github.com/open-feature/ruby-sdk/releases/tag/v0.7.0">
21
- <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.7.0&color=blue&style=for-the-badge" />
20
+ <a href="https://github.com/open-feature/ruby-sdk/releases/tag/v0.8.0">
21
+ <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.8.0&color=blue&style=for-the-badge" />
22
22
  </a>
23
23
 
24
24
  <!-- x-release-please-end -->
@@ -443,6 +443,29 @@ class MyRequestScopedPropagator
443
443
  end
444
444
  ```
445
445
 
446
+ ### Isolated API instances
447
+
448
+ `OpenFeature::SDK` is a global singleton. Some applications need more than one independent API in a process, for example separately developed modules that each need their own provider, or tests that run in parallel. Require the factory explicitly to create one:
449
+
450
+ ```ruby
451
+ require "open_feature/sdk/isolated"
452
+
453
+ api = OpenFeature::SDK::Isolated.create_api
454
+ api.set_provider_and_wait(MyProvider.new)
455
+ client = api.build_client
456
+ client.fetch_boolean_value(flag_key: "my-flag", default_value: false)
457
+ ```
458
+
459
+ Each instance has its own providers, hooks, evaluation context, event handlers, transaction context propagator and `shutdown`, and shares none of them with `OpenFeature::SDK` or other instances.
460
+ Build its clients with `api.build_client`; `OpenFeature::SDK::Client.new` binds to the global API.
461
+ Prefer the global API unless you need this.
462
+
463
+ A provider instance should be registered with only one API at a time.
464
+ Registering a provider that includes `OpenFeature::SDK::Provider::EventEmitter` with a second API raises `ArgumentError`; other providers can't be detected.
465
+ Once the first API replaces the provider or shuts down, it can be registered with another.
466
+
467
+ A `LoggingHook` created without `logger:` logs to the global API's logger, even when added to an isolated API. Pass `logger:` to use another.
468
+
446
469
  ## Extending
447
470
 
448
471
  ### Develop a provider
@@ -543,6 +566,11 @@ class MyLoggingHook
543
566
  end
544
567
  ```
545
568
 
569
+ Hooks can read their inputs but not change them:
570
+
571
+ - Only `before` hooks can change the evaluation context: return an `EvaluationContext` for the SDK to merge in, or assign `hook_context.evaluation_context`. By the time `after`, `error` and `finally` run, it's a frozen copy, so assigning it or writing to its `fields` raises `FrozenError`. Nested field values aren't frozen, because they belong to the caller.
572
+ - The flag key, the default value, the hints, and the client and provider metadata are frozen copies. Your own objects aren't frozen.
573
+
546
574
  > Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs!
547
575
 
548
576
  <!-- x-hide-in-docs-start -->
@@ -32,13 +32,15 @@ module OpenFeature
32
32
  #
33
33
  # client = OpenFeature::SDK::API.instance.build_client(name: 'my-open-feature-client')
34
34
  class API
35
- include Singleton # Satisfies Flag Evaluation API Requirement 1.1.1
35
+ include Singleton # Satisfies Flag Evaluation API Requirement 1.1.1; Isolated.create_api builds others (1.8.1)
36
36
  extend Forwardable
37
37
 
38
38
  def_delegators :configuration, :provider, :set_provider, :set_provider_and_wait, :hooks, :add_hooks, :evaluation_context
39
39
 
40
- def configuration
41
- @configuration ||= Configuration.new
40
+ attr_reader :configuration
41
+
42
+ def initialize
43
+ @configuration = Configuration.new
42
44
  end
43
45
 
44
46
  def configure(&block)
@@ -48,7 +50,7 @@ module OpenFeature
48
50
  end
49
51
 
50
52
  def build_client(domain: nil, evaluation_context: nil)
51
- Client.new(domain:, evaluation_context:)
53
+ Client.new(domain:, evaluation_context:, configuration:)
52
54
  end
53
55
 
54
56
  def provider_metadata(domain: nil)
@@ -21,8 +21,9 @@ module OpenFeature
21
21
 
22
22
  attr_accessor :hooks, :evaluation_context
23
23
 
24
- def initialize(provider: nil, domain: nil, evaluation_context: nil)
24
+ def initialize(provider: nil, domain: nil, evaluation_context: nil, configuration: API.instance.configuration)
25
25
  @provider = provider
26
+ @configuration = configuration
26
27
  @metadata = ClientMetadata.new(domain:)
27
28
  @evaluation_context = evaluation_context
28
29
  @hooks = []
@@ -33,17 +34,17 @@ module OpenFeature
33
34
  end
34
35
 
35
36
  def provider_status
36
- OpenFeature::SDK.configuration.provider_state(resolve_provider)
37
+ @configuration.provider_state(resolve_provider)
37
38
  end
38
39
 
39
40
  def add_handler(event_type, handler = nil, &block)
40
41
  actual_handler = handler || block
41
- OpenFeature::SDK.configuration.add_client_handler(self, event_type, actual_handler)
42
+ @configuration.add_client_handler(self, event_type, actual_handler)
42
43
  end
43
44
 
44
45
  def remove_handler(event_type, handler = nil, &block)
45
46
  actual_handler = handler || block
46
- OpenFeature::SDK.configuration.remove_client_handler(self, event_type, actual_handler)
47
+ @configuration.remove_client_handler(self, event_type, actual_handler)
47
48
  end
48
49
 
49
50
  # Tracking API (spec 6.1.1.1) — dynamic-context paradigm
@@ -81,13 +82,13 @@ module OpenFeature
81
82
 
82
83
  # Assemble ordered hooks: API → Client → Invocation → Provider (spec 4.4.2)
83
84
  provider_hooks = provider.respond_to?(:hooks) ? Array(provider.hooks) : []
84
- ordered_hooks = [*OpenFeature::SDK.hooks, *@hooks, *invocation_hooks, *provider_hooks]
85
+ ordered_hooks = [*@configuration.hooks, *@hooks, *invocation_hooks, *provider_hooks]
85
86
 
86
87
  # Short-circuit NOT_READY/FATAL providers (required by evaluation_v2.feature). A provider resolved from the
87
88
  # configuration is tracked while bound, so one that isn't was replaced mid-call and reads NOT_READY.
88
89
  short_circuit_code = nil
89
- if @provider ? OpenFeature::SDK.configuration.provider_tracked?(provider) : !provider.equal?(@fallback_provider)
90
- short_circuit_code = short_circuit_error_code(OpenFeature::SDK.configuration.provider_state(provider))
90
+ if @provider ? @configuration.provider_tracked?(provider) : !provider.equal?(@fallback_provider)
91
+ short_circuit_code = short_circuit_error_code(@configuration.provider_state(provider))
91
92
  end
92
93
 
93
94
  # Fast path: skip hook ceremony when no hooks are registered
@@ -121,7 +122,7 @@ module OpenFeature
121
122
  EMPTY_HINTS
122
123
  end
123
124
 
124
- executor = Hooks::HookExecutor.new(logger: OpenFeature::SDK.configuration.logger)
125
+ executor = Hooks::HookExecutor.new(logger: @configuration.logger)
125
126
 
126
127
  if short_circuit_code
127
128
  resolution = Provider::ResolutionDetails.new(
@@ -133,7 +134,7 @@ module OpenFeature
133
134
  executor.run_short_circuit(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints, evaluation_details: evaluation_details)
134
135
  evaluation_details
135
136
  else
136
- executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints) do |hctx|
137
+ executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints, default_value:) do |hctx|
137
138
  evaluate_flag(provider: provider, type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
138
139
  end
139
140
  end
@@ -156,11 +157,16 @@ module OpenFeature
156
157
  evaluation_context: evaluation_context
157
158
  )
158
159
 
159
- if resolution_details.error_code || TYPE_CLASS_MAP[type].none? { |klass| resolution_details.value.is_a?(klass) }
160
- resolution_details.value = default_value
161
- resolution_details.error_code ||= Provider::ErrorCode::TYPE_MISMATCH
162
- resolution_details.reason = Provider::Reason::ERROR
163
- resolution_details.variant = nil
160
+ provider_error = resolution_details.error_code
161
+ if provider_error || TYPE_CLASS_MAP[type].none? { |klass| resolution_details.value.is_a?(klass) }
162
+ # A new struct, since a provider may cache and reuse the one it returned
163
+ resolution_details = Provider::ResolutionDetails.new(
164
+ value: default_value,
165
+ reason: Provider::Reason::ERROR,
166
+ error_code: provider_error || Provider::ErrorCode::TYPE_MISMATCH,
167
+ error_message: provider_error ? resolution_details.error_message : "Expected #{type}, got #{resolution_details.value.class}",
168
+ flag_metadata: resolution_details.flag_metadata
169
+ )
164
170
  end
165
171
 
166
172
  EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution_details)
@@ -169,7 +175,7 @@ module OpenFeature
169
175
  # Callers resolve once per operation, so a rebinding mid-evaluation can't mix two providers.
170
176
  # The fallback NoOp is never registered: untracked, so not short-circuited, and its status reads NOT_READY.
171
177
  def resolve_provider
172
- @provider || OpenFeature::SDK.configuration.provider(domain: @metadata.domain) || (@fallback_provider ||= Provider::NoOpProvider.new)
178
+ @provider || @configuration.provider(domain: @metadata.domain) || (@fallback_provider ||= Provider::NoOpProvider.new)
173
179
  end
174
180
 
175
181
  def short_circuit_error_code(state)
@@ -180,11 +186,11 @@ module OpenFeature
180
186
  end
181
187
 
182
188
  def build_evaluation_context(invocation_context)
183
- propagator = OpenFeature::SDK.configuration.transaction_context_propagator
189
+ propagator = @configuration.transaction_context_propagator
184
190
  transaction_context = propagator&.get_transaction_context
185
191
 
186
192
  EvaluationContextBuilder.new.call(
187
- api_context: OpenFeature::SDK.evaluation_context,
193
+ api_context: @configuration.evaluation_context,
188
194
  transaction_context: transaction_context,
189
195
  client_context: evaluation_context,
190
196
  invocation_context: invocation_context
@@ -4,6 +4,11 @@ module OpenFeature
4
4
  module SDK
5
5
  ClientMetadata = Struct.new(:domain, keyword_init: true) do
6
6
  alias_method :name, :domain
7
+
8
+ def initialize(domain: nil)
9
+ super(domain: domain&.dup&.freeze)
10
+ freeze
11
+ end
7
12
  end
8
13
  end
9
14
  end
@@ -162,9 +162,10 @@ module OpenFeature
162
162
  needs_init = attempt.nil? || attempt.state == :failed
163
163
 
164
164
  if needs_init
165
+ # First, so a provider registered with another API (1.8.4) leaves no state behind
166
+ provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
165
167
  attempt = @init_attempts[provider] = InitAttempt.new(:running, nil, Thread::Queue.new)
166
168
  @provider_state_registry.set_initial_state(provider)
167
- provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
168
169
  end
169
170
 
170
171
  # Publish last: clients read @providers without the lock, and must not see an untracked provider
@@ -219,7 +220,7 @@ module OpenFeature
219
220
  end
220
221
  ensure
221
222
  # Fail an attempt no init took over (e.g. a Timeout hit first), or it would stay running forever
222
- finish_init_attempt(attempt, :failed) if needs_init && !init_thread
223
+ finish_init_attempt(attempt, :failed) if needs_init && attempt && !init_thread
223
224
  end
224
225
 
225
226
  def init_provider(provider, context, domain:, attempt:, raise_on_error: false)
@@ -26,6 +26,16 @@ module OpenFeature
26
26
  )
27
27
  end
28
28
 
29
+ def initialize_copy(source)
30
+ super
31
+ @fields = @fields.dup
32
+ end
33
+
34
+ def freeze
35
+ fields.freeze
36
+ super
37
+ end
38
+
29
39
  def ==(other)
30
40
  fields == other.fields
31
41
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenFeature
4
+ module SDK
5
+ module Hooks
6
+ # A deep-frozen copy of a hook input, so hooks can't change it and the caller's object isn't frozen (spec 4.1.3, 4.2.2)
7
+ def self.frozen_copy(value)
8
+ Ractor.make_shareable(value, copy: true)
9
+ rescue
10
+ value.dup.freeze # shortcut: when something inside can't be copied (a Mutex, Queue or Proc), only the top level is frozen
11
+ end
12
+ end
13
+ end
14
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "delegate"
4
+ require_relative "frozen_copy"
4
5
 
5
6
  module OpenFeature
6
7
  module SDK
@@ -13,8 +14,7 @@ module OpenFeature
13
14
  assert_allowed_key(key)
14
15
  assert_allowed_value(value)
15
16
  end
16
- @hash = hash.dup
17
- super(@hash)
17
+ super(Hooks.frozen_copy(hash))
18
18
  freeze
19
19
  end
20
20
 
@@ -1,28 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "frozen_copy"
4
+
3
5
  module OpenFeature
4
6
  module SDK
5
7
  module Hooks
6
8
  # Provides context to hook stages during flag evaluation.
7
9
  #
8
- # Per spec 4.1.1-4.1.5:
10
+ # Per spec 4.1.1-4.1.5 and 4.2.2:
9
11
  # - flag_key, flag_value_type, default_value are immutable (4.1.3)
10
- # - client_metadata, provider_metadata are optional (4.1.2)
11
- # - evaluation_context is mutable (for before hooks to modify, 4.1.4.1)
12
+ # - client_metadata, provider_metadata are optional (4.1.2) and immutable (4.2.2.2, 4.2.2.3)
13
+ # - evaluation_context is mutable only in before hooks; HookExecutor freezes the hook context after them (4.1.4.1)
12
14
  class HookContext
13
- attr_reader :flag_key, :flag_value_type, :default_value,
14
- :client_metadata, :provider_metadata
15
+ attr_reader :flag_key, :flag_value_type, :client_metadata, :provider_metadata
15
16
  attr_accessor :evaluation_context
16
17
 
17
18
  def initialize(flag_key:, flag_value_type:, default_value:, evaluation_context:,
18
19
  client_metadata: nil, provider_metadata: nil)
19
- @flag_key = flag_key.freeze
20
+ @flag_key = Hooks.frozen_copy(flag_key)
20
21
  @flag_value_type = flag_value_type.freeze
21
- @default_value = default_value.freeze
22
+ @source_default_value = default_value
22
23
  @evaluation_context = evaluation_context
23
24
  @client_metadata = client_metadata
24
- @provider_metadata = provider_metadata
25
+ @provider_metadata = Hooks.frozen_copy(provider_metadata)
25
26
  @hook_data = {}
27
+ @copies = {}
28
+ end
29
+
30
+ # Copied on first read: most hooks never read it, and the copy grows with the value
31
+ def default_value
32
+ @copies.fetch(:default_value) { @copies[:default_value] = Hooks.frozen_copy(@source_default_value) }
33
+ end
34
+
35
+ # Swaps in a frozen copy of the evaluation context, so neither the caller's nor a before hook's object is frozen.
36
+ # Top-level strings are copied too, so a hook can't edit a targeting key in place.
37
+ def freeze
38
+ return self if frozen?
39
+ @evaluation_context &&= @evaluation_context.dup.tap do |copy|
40
+ copy.fields.transform_values! { |value| (value.is_a?(String) && !value.frozen?) ? value.dup.freeze : value }
41
+ end.freeze
42
+ super
26
43
  end
27
44
 
28
45
  # Returns a mutable hash scoped to the given hook instance.
@@ -13,6 +13,9 @@ module OpenFeature
13
13
  # - Before/after hook error → stop remaining hooks, run error hooks, return default
14
14
  # - Error hook error → log, continue remaining error hooks
15
15
  # - Finally hook error → log, continue remaining finally hooks
16
+ #
17
+ # The hook context is frozen before after, error and finally hooks run, so only before hooks
18
+ # can change the evaluation context (spec 4.1.4.1). Not earlier: some providers write to it.
16
19
  class HookExecutor
17
20
  def initialize(logger: nil)
18
21
  @logger = logger
@@ -37,9 +40,10 @@ module OpenFeature
37
40
  # @param ordered_hooks [Array] hooks in before-order (API, Client, Invocation, Provider)
38
41
  # @param hook_context [HookContext] the hook context
39
42
  # @param hints [Hints] hook hints
43
+ # @param default_value [Object] returned if evaluation raises; the caller's own object, not the hooks' frozen copy
40
44
  # @param evaluate_block [Proc] the flag evaluation to wrap
41
45
  # @return [EvaluationDetails] the evaluation result
42
- def execute(ordered_hooks:, hook_context:, hints:, &evaluate_block)
46
+ def execute(ordered_hooks:, hook_context:, hints:, default_value:, &evaluate_block)
43
47
  evaluation_details = nil
44
48
 
45
49
  begin
@@ -60,7 +64,7 @@ module OpenFeature
60
64
  evaluation_details = EvaluationDetails.new(
61
65
  flag_key: hook_context.flag_key,
62
66
  resolution_details: Provider::ResolutionDetails.new(
63
- value: hook_context.default_value,
67
+ value: default_value,
64
68
  error_code: EvaluationError.code_for(e),
65
69
  reason: Provider::Reason::ERROR,
66
70
  error_message: e.message
@@ -91,6 +95,7 @@ module OpenFeature
91
95
 
92
96
  # Spec 4.4.2: After hooks run in reverse order: Provider → Invocation → Client → API
93
97
  def run_after_hooks(hooks, hook_context, evaluation_details, hints)
98
+ hook_context.freeze
94
99
  hooks.reverse_each do |hook|
95
100
  next unless hook.respond_to?(:after)
96
101
  hook.after(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
@@ -100,6 +105,7 @@ module OpenFeature
100
105
  # Spec 4.4.4: Error hooks run in reverse order.
101
106
  # If an error hook itself errors, log and continue remaining error hooks.
102
107
  def run_error_hooks(hooks, hook_context, exception, hints)
108
+ hook_context.freeze
103
109
  hooks.reverse_each do |hook|
104
110
  next unless hook.respond_to?(:error)
105
111
  hook.error(hook_context: hook_context, exception: exception, hints: hints)
@@ -111,6 +117,7 @@ module OpenFeature
111
117
  # Spec 4.4.3: Finally hooks run in reverse order unconditionally.
112
118
  # If a finally hook errors, log and continue remaining finally hooks.
113
119
  def run_finally_hooks(hooks, hook_context, evaluation_details, hints)
120
+ hook_context.freeze
114
121
  hooks.reverse_each do |hook|
115
122
  next unless hook.respond_to?(:finally)
116
123
  hook.finally(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sdk"
4
+
5
+ module OpenFeature
6
+ module SDK
7
+ # Spec 1.8.3: a separate require, so isolated instances are an explicit opt-in
8
+ module Isolated
9
+ # Spec 1.8.1: a new API that shares no state with the global one or other isolated APIs
10
+ def self.create_api
11
+ API.send(:new)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -8,7 +8,7 @@ module OpenFeature
8
8
  # Mixin for providers that emit lifecycle events
9
9
  module EventEmitter
10
10
  def emit_event(event_type, details = {})
11
- config = @configuration
11
+ config = @openfeature_configuration
12
12
  return unless config
13
13
 
14
14
  unless ::OpenFeature::SDK::ProviderEvent::ALL_EVENTS.include?(event_type)
@@ -19,17 +19,22 @@ module OpenFeature
19
19
  end
20
20
 
21
21
  def configuration_attached?
22
- !@configuration.nil?
22
+ !@openfeature_configuration.nil?
23
23
  end
24
24
 
25
25
  private
26
26
 
27
27
  def attach(configuration)
28
- @configuration = configuration
28
+ # Spec 1.8.4: only providers using this mixin know which API they're registered with
29
+ if @openfeature_configuration && !@openfeature_configuration.equal?(configuration)
30
+ raise ArgumentError, "Provider is already registered with another OpenFeature API instance"
31
+ end
32
+
33
+ @openfeature_configuration = configuration
29
34
  end
30
35
 
31
36
  def detach
32
- @configuration = nil
37
+ @openfeature_configuration = nil
33
38
  end
34
39
  end
35
40
  end
@@ -23,7 +23,7 @@ module OpenFeature
23
23
  # * <tt>fetch_object_value</tt> - Retrieve feature flag object value
24
24
  #
25
25
  class NoOpProvider
26
- REASON_NO_OP = "No-op"
26
+ REASON_NO_OP = Reason::DEFAULT
27
27
  NAME = "No-op Provider"
28
28
 
29
29
  attr_reader :metadata
@@ -21,8 +21,8 @@ module OpenFeature
21
21
  # @param message [String] the error message
22
22
  # @param provider [Object] the provider that failed to initialize
23
23
  # @param original_error [Exception] the original error that caused the failure
24
- # @param error_code [String] the OpenFeature error code (defaults to PROVIDER_FATAL)
25
- def initialize(message, provider: nil, original_error: nil, error_code: Provider::ErrorCode::PROVIDER_FATAL)
24
+ # @param error_code [String] the OpenFeature error code (defaults to GENERAL)
25
+ def initialize(message, provider: nil, original_error: nil, error_code: Provider::ErrorCode::GENERAL)
26
26
  super(message)
27
27
  @provider = provider
28
28
  @original_error = original_error
@@ -9,7 +9,7 @@ module OpenFeature
9
9
  # Tracks provider states
10
10
  class ProviderStateRegistry
11
11
  def initialize
12
- @states = {}
12
+ @states = {}.compare_by_identity
13
13
  @mutex = Mutex.new
14
14
  end
15
15
 
@@ -17,7 +17,7 @@ module OpenFeature
17
17
  return unless provider
18
18
 
19
19
  @mutex.synchronize do
20
- @states[provider.object_id] = {state: state, details: {}}
20
+ @states[provider] = {state: state, details: {}}
21
21
  end
22
22
  end
23
23
 
@@ -29,7 +29,7 @@ module OpenFeature
29
29
  # Only update state if the event should cause a state change
30
30
  if new_state
31
31
  @mutex.synchronize do
32
- @states[provider.object_id] = {state: new_state, details: event_details || {}}
32
+ @states[provider] = {state: new_state, details: event_details || {}}
33
33
  end
34
34
  new_state
35
35
  else
@@ -42,7 +42,7 @@ module OpenFeature
42
42
  return ProviderState::NOT_READY unless provider
43
43
 
44
44
  @mutex.synchronize do
45
- entry = @states[provider.object_id]
45
+ entry = @states[provider]
46
46
  entry ? entry[:state] : ProviderState::NOT_READY
47
47
  end
48
48
  end
@@ -51,7 +51,7 @@ module OpenFeature
51
51
  return {} unless provider
52
52
 
53
53
  @mutex.synchronize do
54
- entry = @states[provider.object_id]
54
+ entry = @states[provider]
55
55
  entry ? entry[:details] : {}
56
56
  end
57
57
  end
@@ -60,7 +60,7 @@ module OpenFeature
60
60
  return unless provider
61
61
 
62
62
  @mutex.synchronize do
63
- @states.delete(provider.object_id)
63
+ @states.delete(provider)
64
64
  end
65
65
  end
66
66
 
@@ -68,7 +68,7 @@ module OpenFeature
68
68
  return false unless provider
69
69
 
70
70
  @mutex.synchronize do
71
- @states.key?(provider.object_id)
71
+ @states.key?(provider)
72
72
  end
73
73
  end
74
74
 
@@ -5,14 +5,19 @@ module OpenFeature
5
5
  class ThreadLocalTransactionContextPropagator
6
6
  include TransactionContextPropagator
7
7
 
8
+ # One context per propagator under this key, so separate APIs never share one (spec 1.8.2)
9
+ # while resetting the key still clears them all. Weak keys let discarded propagators be collected.
8
10
  THREAD_KEY = :openfeature_transaction_context
9
11
 
10
12
  def set_transaction_context(evaluation_context)
11
- Thread.current[THREAD_KEY] = evaluation_context
13
+ contexts = Thread.current[THREAD_KEY]
14
+ contexts = Thread.current[THREAD_KEY] = ObjectSpace::WeakKeyMap.new unless contexts.is_a?(ObjectSpace::WeakKeyMap)
15
+ evaluation_context.nil? ? contexts.delete(self) : contexts[self] = evaluation_context
12
16
  end
13
17
 
14
18
  def get_transaction_context
15
- Thread.current[THREAD_KEY]
19
+ contexts = Thread.current[THREAD_KEY]
20
+ contexts[self] if contexts.is_a?(ObjectSpace::WeakKeyMap)
16
21
  end
17
22
  end
18
23
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OpenFeature
4
4
  module SDK
5
- VERSION = "0.7.0"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
@@ -1,10 +1,13 @@
1
1
  module OpenFeature
2
2
  module SDK
3
3
  class API
4
- include Singleton
5
4
  extend Forwardable
6
5
 
7
- def configuration: () -> Configuration
6
+ include Singleton
7
+
8
+ attr_reader configuration: Configuration
9
+
10
+ def initialize: () -> void
8
11
 
9
12
  def configure: () { (Configuration) -> void } -> void
10
13
 
@@ -10,7 +10,7 @@ module OpenFeature
10
10
  attr_accessor evaluation_context: EvaluationContext?
11
11
  attr_accessor hooks: Array[untyped]
12
12
 
13
- def initialize: (?provider: untyped, ?domain: String?, ?evaluation_context: EvaluationContext?) -> void
13
+ def initialize: (?provider: untyped, ?domain: String?, ?evaluation_context: EvaluationContext?, ?configuration: Configuration) -> void
14
14
 
15
15
  def add_hooks: (*Array[untyped] new_hooks) -> void
16
16
 
@@ -13,6 +13,10 @@ module OpenFeature
13
13
 
14
14
  def merge: (EvaluationContext overriding_context) -> EvaluationContext
15
15
 
16
+ def initialize_copy: (EvaluationContext source) -> void
17
+
18
+ def freeze: () -> self
19
+
16
20
  def ==: (untyped other) -> bool
17
21
  end
18
22
  end
@@ -0,0 +1,7 @@
1
+ module OpenFeature
2
+ module SDK
3
+ module Hooks
4
+ def self.frozen_copy: [T] (T value) -> T
5
+ end
6
+ end
7
+ end
@@ -4,13 +4,16 @@ module OpenFeature
4
4
  class HookContext
5
5
  attr_reader flag_key: String
6
6
  attr_reader flag_value_type: Symbol
7
- attr_reader default_value: untyped
8
7
  attr_reader client_metadata: ClientMetadata?
9
8
  attr_reader provider_metadata: Provider::ProviderMetadata?
10
9
  attr_accessor evaluation_context: EvaluationContext?
11
10
 
12
11
  def initialize: (flag_key: String, flag_value_type: Symbol, default_value: untyped, evaluation_context: EvaluationContext?, ?client_metadata: ClientMetadata?, ?provider_metadata: Provider::ProviderMetadata?) -> void
13
12
 
13
+ def default_value: () -> untyped
14
+
15
+ def freeze: () -> self
16
+
14
17
  def hook_data_for: (untyped hook) -> Hash[untyped, untyped]
15
18
  end
16
19
  end
@@ -6,7 +6,7 @@ module OpenFeature
6
6
 
7
7
  def run_short_circuit: (ordered_hooks: Array[untyped], hook_context: HookContext, hints: Hints, evaluation_details: EvaluationDetails) -> void
8
8
 
9
- def execute: (ordered_hooks: Array[untyped], hook_context: HookContext, hints: Hints) { (HookContext) -> EvaluationDetails } -> EvaluationDetails?
9
+ def execute: (ordered_hooks: Array[untyped], hook_context: HookContext, hints: Hints, default_value: untyped) { (HookContext) -> EvaluationDetails } -> EvaluationDetails?
10
10
 
11
11
  private
12
12
 
@@ -0,0 +1,7 @@
1
+ module OpenFeature
2
+ module SDK
3
+ module Isolated
4
+ def self.create_api: () -> API
5
+ end
6
+ end
7
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openfeature-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - OpenFeature Authors
@@ -50,11 +50,13 @@ files:
50
50
  - lib/open_feature/sdk/evaluation_error.rb
51
51
  - lib/open_feature/sdk/event_dispatcher.rb
52
52
  - lib/open_feature/sdk/hooks.rb
53
+ - lib/open_feature/sdk/hooks/frozen_copy.rb
53
54
  - lib/open_feature/sdk/hooks/hints.rb
54
55
  - lib/open_feature/sdk/hooks/hook.rb
55
56
  - lib/open_feature/sdk/hooks/hook_context.rb
56
57
  - lib/open_feature/sdk/hooks/hook_executor.rb
57
58
  - lib/open_feature/sdk/hooks/logging_hook.rb
59
+ - lib/open_feature/sdk/isolated.rb
58
60
  - lib/open_feature/sdk/provider.rb
59
61
  - lib/open_feature/sdk/provider/error_code.rb
60
62
  - lib/open_feature/sdk/provider/event_emitter.rb
@@ -87,11 +89,13 @@ files:
87
89
  - sig/open_feature/sdk/evaluation_details.rbs
88
90
  - sig/open_feature/sdk/evaluation_error.rbs
89
91
  - sig/open_feature/sdk/event_dispatcher.rbs
92
+ - sig/open_feature/sdk/hooks/frozen_copy.rbs
90
93
  - sig/open_feature/sdk/hooks/hints.rbs
91
94
  - sig/open_feature/sdk/hooks/hook.rbs
92
95
  - sig/open_feature/sdk/hooks/hook_context.rbs
93
96
  - sig/open_feature/sdk/hooks/hook_executor.rbs
94
97
  - sig/open_feature/sdk/hooks/logging_hook.rbs
98
+ - sig/open_feature/sdk/isolated.rbs
95
99
  - sig/open_feature/sdk/provider.rbs
96
100
  - sig/open_feature/sdk/provider/error_code.rbs
97
101
  - sig/open_feature/sdk/provider/event_emitter.rbs