openfeature-sdk 0.7.1 → 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: bf1041c34e962f832b25f1be3e9eeab7fee3fd4272dc9e1e0e8dabe85b6d21e8
4
- data.tar.gz: f45667a6a898d3521ea87c31aea62491ace0682c72ef2cfa2a16d5f8a0f3f0b5
3
+ metadata.gz: a637edf0b0a85dbfbaf655066912a64a21c1cd4a467b4fe557208c10184df3a9
4
+ data.tar.gz: 6fa0f3f6e534279a8e1b6296e1e8b524decd315c99e4bc6238575f3867a8a428
5
5
  SHA512:
6
- metadata.gz: 393adb8b637763e5b397f6ee702017705bf355c8785ceb1280aa4f95b585a061f8da6e4a6d67ed9e8ab20de1f341b36d4dc3febb38dbbcc7a41a68cfb8002b8c
7
- data.tar.gz: d145cc98fb609aa75ad06485d4486cc6aec19ea953cd627f9f71b33e991fafd3a57fa139fbe07f878e5bc105885ce35a74c0ab12485e50649d63b79a100eec98
6
+ metadata.gz: d16e851abe52d3bbbac68aa9180a9c1ace7b7cf97ce3508c21e083d7780825b862a608a7f438598d6bcb44c50efeceb84148c2f5351d544cb5584546d2cb2c22
7
+ data.tar.gz: 18c44f236a822a02469a4ed66b297c607e7e59d440428266790ce1a95da21a549e122c605d7d1f9c70af9442d8f6ce8c0c7ff0242e1f69268ce242bb7dbb02b8
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.7.1"
2
+ ".": "0.8.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
3
15
  ## [0.7.1](https://github.com/open-feature/ruby-sdk/compare/v0.7.0...v0.7.1) (2026-10-23)
4
16
 
5
17
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- openfeature-sdk (0.7.1)
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.1">
21
- <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.7.1&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 -->
@@ -566,6 +566,11 @@ class MyLoggingHook
566
566
  end
567
567
  ```
568
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
+
569
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!
570
575
 
571
576
  <!-- x-hide-in-docs-start -->
@@ -134,7 +134,7 @@ module OpenFeature
134
134
  executor.run_short_circuit(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints, evaluation_details: evaluation_details)
135
135
  evaluation_details
136
136
  else
137
- 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|
138
138
  evaluate_flag(provider: provider, type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
139
139
  end
140
140
  end
@@ -157,11 +157,16 @@ module OpenFeature
157
157
  evaluation_context: evaluation_context
158
158
  )
159
159
 
160
- if resolution_details.error_code || TYPE_CLASS_MAP[type].none? { |klass| resolution_details.value.is_a?(klass) }
161
- resolution_details.value = default_value
162
- resolution_details.error_code ||= Provider::ErrorCode::TYPE_MISMATCH
163
- resolution_details.reason = Provider::Reason::ERROR
164
- 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
+ )
165
170
  end
166
171
 
167
172
  EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution_details)
@@ -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)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OpenFeature
4
4
  module SDK
5
- VERSION = "0.7.1"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
@@ -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
 
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.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - OpenFeature Authors
@@ -50,6 +50,7 @@ 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
@@ -88,6 +89,7 @@ files:
88
89
  - sig/open_feature/sdk/evaluation_details.rbs
89
90
  - sig/open_feature/sdk/evaluation_error.rbs
90
91
  - sig/open_feature/sdk/event_dispatcher.rbs
92
+ - sig/open_feature/sdk/hooks/frozen_copy.rbs
91
93
  - sig/open_feature/sdk/hooks/hints.rbs
92
94
  - sig/open_feature/sdk/hooks/hook.rbs
93
95
  - sig/open_feature/sdk/hooks/hook_context.rbs