yes-core 1.3.0 → 2.3.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: 0a7213daeb56ae07a8921c83b912e83b7f9cd81f6d93d90a4394cbe07b7c9299
4
- data.tar.gz: 58379a04830d2ba08c0ca4431d4e372ee8cc67f8445621d7143c61f1dc27ab9a
3
+ metadata.gz: '06386ad1e1b30ddacbb4afaf392632875023650518041e3914178427039f91f5'
4
+ data.tar.gz: af40c3f3d98c611ce0653dfb0c0e2e2193376feb1ed0aeba69d43827fd6a09f9
5
5
  SHA512:
6
- metadata.gz: f5ce8a14fb78e7c9bc1b11264e16a17f0cbdc0ef3dcbbd7175114a1c772097b67cb1226f359429a6be83900f26d0e911dd18df838aa9c9ad5a76cab5d999b889
7
- data.tar.gz: 01de285d8c90f81625bdd1ff721877b87d23edad1949b1eb860ebd4fc0274c459fbbeb3080e6ecb678fa1cd0174b010b4eb4f146ff00d6e4bea4be935685bb08
6
+ metadata.gz: e956ac8483b7a53f13009356536c221f92dc2dd6d88f6137ca4d1e6305a7c239a2870484197469243c14dca6552983d0004b263147ad6642d94c17611415cfd6
7
+ data.tar.gz: 5ac3c8ea889ddf9a01efc1ac645a35c9d394736f351e1f98d9ca1dc0f5550094ded103ba424f2a8df07f24d46a082948a69e719aab3aa330d18736d0815de17a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.3.0] - 2026-09-03
4
+ - See root CHANGELOG.md for details.
5
+
6
+ ## [2.2.0] - 2026-09-01
7
+
8
+ - See root CHANGELOG.md for details.
9
+
10
+ ## [1.4.0] - 2026-06-24
11
+
12
+ - See root CHANGELOG.md for details.
13
+
14
+ ## [1.3.1] - 2026-06-16
15
+
16
+ - See root CHANGELOG.md for details.
17
+
3
18
  ## [1.3.0] - 2026-05-18
4
19
 
5
20
  - See root CHANGELOG.md for details.
data/README.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  Core event sourcing framework providing the aggregate DSL, commands, events, read models, and supporting infrastructure for the [Yes](https://github.com/yousty/yes) framework.
4
4
 
5
+ ## Compatibility with pg_eventstore
6
+
7
+ ⚠️ **pg_eventstore 3.x requires yes-core >= 2.1.1.** pg_eventstore 3.0.0 generates event
8
+ ids with `SecureRandom.uuid_v7` (it moved the default off the database's
9
+ `gen_random_uuid()`, which always produced v4). Those ids arrive as `causation_id` /
10
+ `correlation_id`, and `Types::UUID` accepted only v4 before 2.1.1 — so
11
+ `TransactionDetails.new` raised `Dry::Struct::Error`, the event handler failed, and the
12
+ subscription died once its restarts ran out.
13
+
14
+ `Types::UUID` now accepts any RFC 9562 version (1-8) while still requiring a valid
15
+ version and variant nibble.
16
+
17
+
5
18
  ## Installation
6
19
 
7
20
  Add this line to your application's Gemfile:
@@ -3,22 +3,25 @@
3
3
  module Yes
4
4
  module Core
5
5
  module ActiveJobSerializers
6
- # ActiveJob serializer for CommandGroup objects.
6
+ # ActiveJob serializer for {Yes::Core::Commands::Group} (legacy stateless
7
+ # cross-aggregate groups) and {Yes::Core::Commands::CommandGroup}
8
+ # (aggregate-DSL groups). Both round-trip through `to_h` / `Class.new`.
7
9
  class CommandGroupSerializer < ActiveJob::Serializers::ObjectSerializer
8
10
  # @param argument [Object] the argument to check
9
11
  # @return [Boolean] true if the argument can be serialized
10
12
  def serialize?(argument)
11
- argument.is_a? Yes::Core::Commands::Group
13
+ argument.is_a?(Yes::Core::Commands::Group) ||
14
+ argument.is_a?(Yes::Core::Commands::CommandGroup)
12
15
  end
13
16
 
14
- # @param command_group [Yes::Core::Commands::Group] the command group to serialize
17
+ # @param command_group [Yes::Core::Commands::Group, Yes::Core::Commands::CommandGroup]
15
18
  # @return [Hash] the serialized representation
16
19
  def serialize(command_group)
17
20
  super(command_group.to_h.merge(_type: command_group.class.name))
18
21
  end
19
22
 
20
23
  # @param hash [Hash] the serialized representation
21
- # @return [Yes::Core::Commands::Group] the deserialized command group
24
+ # @return [Yes::Core::Commands::Group, Yes::Core::Commands::CommandGroup]
22
25
  def deserialize(hash)
23
26
  symbolized_hash = hash.deep_symbolize_keys
24
27
  Object.const_get(symbolized_hash[:_type]).new(symbolized_hash.except(:_aj_serialized, :_type))
@@ -36,7 +36,8 @@ module Yes
36
36
 
37
37
  raise_command_unauthorized_error!(decision)
38
38
  end
39
- otl_trackable :call, OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Cerbos Authorize Command')
39
+ otl_trackable :call,
40
+ OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Cerbos Authorize Command', track_sql: true)
40
41
 
41
42
  private
42
43
 
@@ -60,6 +61,10 @@ module Yes
60
61
  raise self::CommandNotAuthorized, msg
61
62
  end
62
63
 
64
+ # Loads the resource the command acts on. Commands batched into one request
65
+ # commonly target the same resource, so the lookup is cached for the duration
66
+ # of the authorization pass (see {LookupCache}).
67
+ #
63
68
  # @param command [Yes::Core::Command] command to authorize
64
69
  # @return [ActiveRecord::Base] resource to authorize
65
70
  # @raise [StandardError] if RESOURCE[:name] or RESOURCE[:read_model] is not defined
@@ -70,7 +75,10 @@ module Yes
70
75
  raise StandardError, message
71
76
  end
72
77
 
73
- read_model(command).find_by(id: command.send("#{self::RESOURCE[:name]}_id"))
78
+ model = read_model(command)
79
+ id = command.send("#{self::RESOURCE[:name]}_id")
80
+
81
+ LookupCache.fetch([:resource, model, id]) { model.find_by(id:) }
74
82
  end
75
83
 
76
84
  # Returns the appropriate read model class for the command.
@@ -145,12 +153,19 @@ module Yes
145
153
  resource&.try(:auth_attributes)&.as_json || {}
146
154
  end
147
155
 
156
+ # Builds the principal data for the request. It is derived purely from the
157
+ # auth data, which is the same for every command of a batch, so it is built
158
+ # once per authorization pass (see {LookupCache}). The result is shared
159
+ # between commands and must not be mutated.
160
+ #
148
161
  # @param auth_data [Hash] authorization data
149
162
  # @return [Hash] principal data for Cerbos check_resource
150
163
  def principal_data(auth_data)
151
- Yes::Core.configuration.cerbos_principal_data_builder.call(
152
- auth_data.with_indifferent_access
153
- )
164
+ data = auth_data.with_indifferent_access
165
+
166
+ LookupCache.fetch([:principal_data, data]) do
167
+ Yes::Core.configuration.cerbos_principal_data_builder.call(data)
168
+ end
154
169
  end
155
170
 
156
171
  # @param auth_data [Hash]
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Authorization
6
+ # Scoped memoization for the read-only lookups an authorization pass repeats.
7
+ #
8
+ # Authorizing a batch of commands resolves the same two things once per command:
9
+ # the principal data (which depends only on the request's auth data) and the
10
+ # authorized resource (which the commands of a batch commonly share). Both are
11
+ # pure reads, and a batch is authorized in full before any of its commands is
12
+ # executed, so no write can invalidate them while the pass is running.
13
+ #
14
+ # Caching is only active inside {.with_scope}. Outside one, {.fetch} just yields,
15
+ # so callers keep their uncached behaviour unless they opt in. The store lives in
16
+ # ActiveSupport::IsolatedExecutionState, which keeps it per thread/fiber, and
17
+ # {.with_scope} always clears it on the way out so nothing leaks into the next
18
+ # request.
19
+ #
20
+ # Cached values are shared by every {.fetch} for the same key, so callers must
21
+ # treat them as read-only.
22
+ #
23
+ # @example Caching the lookups of one authorization pass
24
+ # LookupCache.with_scope do
25
+ # commands.each { |command| authorizer_for(command).call(command, auth_data) }
26
+ # end
27
+ class LookupCache
28
+ STORE_KEY = :yes_core_authorization_lookup_cache
29
+
30
+ class << self
31
+ # Runs the block with caching enabled, clearing the cache afterwards.
32
+ # A nested scope reuses the cache of the outermost one and leaves clearing
33
+ # to it.
34
+ #
35
+ # @yield the block to run with caching enabled
36
+ # @return [Object] the block's return value
37
+ def with_scope
38
+ return yield if active?
39
+
40
+ ActiveSupport::IsolatedExecutionState[STORE_KEY] = {}
41
+
42
+ begin
43
+ yield
44
+ ensure
45
+ ActiveSupport::IsolatedExecutionState.delete(STORE_KEY)
46
+ end
47
+ end
48
+
49
+ # Returns the value cached under key, computing it via the block on a miss.
50
+ # Without an open scope the block's value is returned uncached.
51
+ #
52
+ # @param key [Object] cache key
53
+ # @yield computes the value when it is not cached yet
54
+ # @return [Object] the cached or freshly computed value
55
+ def fetch(key)
56
+ return yield unless active?
57
+
58
+ store = ActiveSupport::IsolatedExecutionState[STORE_KEY]
59
+ return store[key] if store.key?(key)
60
+
61
+ store[key] = yield
62
+ end
63
+
64
+ # @return [Boolean] whether a scope is currently open
65
+ def active?
66
+ ActiveSupport::IsolatedExecutionState.key?(STORE_KEY)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -55,6 +55,8 @@ module Yes
55
55
  # response = executor.call(command, guard_evaluator_class)
56
56
  #
57
57
  class CommandExecutor
58
+ include RevisionConflictWaiting
59
+
58
60
  MAX_RETRIES = 10
59
61
  INLINE_RECOVERY_RETRY_THRESHOLD = 5
60
62
 
@@ -96,13 +98,15 @@ module Yes
96
98
  rescue PgEventstore::WrongExpectedRevisionError => e
97
99
  retries += 1
98
100
  clear_pending_update_state if aggregate.class.read_model_enabled?
101
+ raise e if retries > MAX_RETRIES
99
102
 
100
- retries <= MAX_RETRIES ? retry : raise(e)
103
+ wait_for_read_model(e, retries)
104
+ retry
101
105
  rescue ConcurrentUpdateError => e
102
106
  retries += 1
103
107
  # Don't clear pending state - another process owns it
104
108
  # Sleep with exponential backoff to give the other process time to finish
105
- sleep([0.01 * (2**(retries - 1)), 1.0].min) if retries <= MAX_RETRIES
109
+ sleep(RevisionConflictBackoff.schedule(retries)) if retries <= MAX_RETRIES
106
110
 
107
111
  # After several retries, check if pending state is stuck and attempt recovery
108
112
  # This prevents infinite retry loops when a process crashes leaving the flag set
@@ -17,6 +17,8 @@ module Yes
17
17
  # Only the group's own guards run here — sub-command guards are
18
18
  # bypassed by design.
19
19
  class CommandGroupExecutor
20
+ include RevisionConflictWaiting
21
+
20
22
  MAX_RETRIES = 10
21
23
  INLINE_RECOVERY_RETRY_THRESHOLD = 5
22
24
 
@@ -47,10 +49,13 @@ module Yes
47
49
  rescue PgEventstore::WrongExpectedRevisionError => e
48
50
  retries += 1
49
51
  clear_pending_update_state if aggregate.class.read_model_enabled?
50
- retries <= MAX_RETRIES ? retry : raise(e)
52
+ raise e if retries > MAX_RETRIES
53
+
54
+ wait_for_read_model(e, retries)
55
+ retry
51
56
  rescue ConcurrentUpdateError => e
52
57
  retries += 1
53
- sleep([0.01 * (2**(retries - 1)), 1.0].min) if retries <= MAX_RETRIES
58
+ sleep(RevisionConflictBackoff.schedule(retries)) if retries <= MAX_RETRIES
54
59
 
55
60
  if aggregate.class.read_model_enabled? && retries >= INLINE_RECOVERY_RETRY_THRESHOLD
56
61
  ReadModelRecoveryService.attempt_inline_recovery(read_model, aggregate: aggregate)
@@ -169,7 +174,8 @@ module Yes
169
174
  PgEventstore.client.append_to_stream(
170
175
  utils.build_stream(metadata: sub_cmd.metadata || {}),
171
176
  event,
172
- options: { expected_revision: :any }
177
+ options: { expected_revision: :any },
178
+ middlewares: Middlewares.for_write
173
179
  )
174
180
  end
175
181
 
@@ -90,7 +90,8 @@ module Yes
90
90
  PgEventstore.client.append_to_stream(
91
91
  command_utilities.build_stream(metadata:),
92
92
  event,
93
- options: { expected_revision: }
93
+ options: { expected_revision: },
94
+ middlewares: Middlewares.for_write
94
95
  ).tap { otl_record_response(_1) }
95
96
  end
96
97
 
@@ -105,16 +106,20 @@ module Yes
105
106
  name: aggregate_data[:name],
106
107
  id: aggregate_data[:id]
107
108
  )
108
- expected_revision = command_utilities.stream_revision(stream)
109
+ stream_revision = command_utilities.stream_revision(stream)
109
110
  aggregate_revision = aggregate_data[:revision].call
110
- normalized_revision = aggregate_revision == -1 ? :no_stream : aggregate_revision
111
-
112
- next if normalized_revision == expected_revision
111
+ expected_revision = aggregate_revision == -1 ? :no_stream : aggregate_revision
112
+ next if expected_revision == stream_revision
113
113
 
114
+ # Same argument convention as pg_eventstore itself: `revision` is what the
115
+ # store holds, `expected_revision` what we held. pg_eventstore 3.0 requires
116
+ # `verdict:`, which selects the error's message; this branch is only reached
117
+ # when the two differ, which is exactly :unmatched_stream_revision.
114
118
  raise PgEventstore::WrongExpectedRevisionError.new(
115
- revision: aggregate_revision,
119
+ revision: stream_revision,
116
120
  expected_revision:,
117
- stream:
121
+ stream:,
122
+ verdict: :unmatched_stream_revision
118
123
  )
119
124
  end
120
125
  end
@@ -175,7 +180,7 @@ module Yes
175
180
  timestamp: result.created_at,
176
181
  attributes: {
177
182
  'event.type' => result.type,
178
- 'event.link_id' => result.link_id || '',
183
+ 'event.link_global_position' => result.link_global_position || '',
179
184
  'global_position' => result.global_position,
180
185
  'stream' => result.stream.to_json,
181
186
  'stream.revision' => result.stream_revision,
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module CommandHandling
6
+ # Decides how long an executor waits before retrying a command after a
7
+ # PgEventstore::WrongExpectedRevisionError.
8
+ #
9
+ # The executors derive the expected revision from the read model's revision
10
+ # column. When the conflicting append came from this service, the read model
11
+ # was updated in the same process, so the very next attempt already sees the
12
+ # new revision and any wait only adds latency. When another service appended
13
+ # to the stream, the column only advances once this service's event listener
14
+ # has projected that event, typically a few hundred milliseconds later.
15
+ # Retrying immediately then burns every attempt inside the same half second
16
+ # and the command surfaces as a 500.
17
+ #
18
+ # The error carries the stream's actual revision, so for the aggregate's own
19
+ # stream the two cases can be told apart: retry at once while the reloaded
20
+ # read model has caught up, back off while it is still behind. A conflict on
21
+ # an external aggregate's stream (see EventPublisher#verify_external_revisions!)
22
+ # cannot be checked here and always backs off.
23
+ #
24
+ # Delays follow the same 10 ms doubling schedule as the ConcurrentUpdateError
25
+ # branch, capped per attempt and in total, with jitter so that requests which
26
+ # collided once do not retry in lockstep.
27
+ class RevisionConflictBackoff
28
+ # @return [Float] delay of the first waiting attempt, in seconds
29
+ BASE_DELAY_SECONDS = 0.01
30
+ # @return [Float] longest delay of a single attempt, in seconds
31
+ MAX_DELAY_SECONDS = 1.0
32
+ # @return [Float] total sleep budget across all retries of one command, in seconds
33
+ TOTAL_BUDGET_SECONDS = 2.0
34
+ # @return [Float] fraction by which a delay is randomised in both directions
35
+ JITTER_FRACTION = 0.25
36
+
37
+ class << self
38
+ # @param error [PgEventstore::WrongExpectedRevisionError] the conflict that was raised
39
+ # @param aggregate_id [String] id of the aggregate the executor works on; used to tell its own
40
+ # stream from an external aggregate's stream
41
+ # @param read_model_revision [Integer, nil] the revision the read model reports after a
42
+ # reload, or nil when the aggregate has no read model
43
+ # @param attempt [Integer] the 1-based retry attempt about to be made
44
+ # @return [Float] seconds to wait before retrying, 0.0 to retry immediately
45
+ def delay(error:, aggregate_id:, read_model_revision:, attempt:)
46
+ return 0.0 unless worth_waiting?(error, aggregate_id, read_model_revision)
47
+
48
+ remaining = TOTAL_BUDGET_SECONDS - waited_before(attempt)
49
+ return 0.0 unless remaining.positive?
50
+
51
+ jittered([schedule(attempt), remaining].min)
52
+ end
53
+
54
+ # The undisturbed exponential schedule, shared with the ConcurrentUpdateError retries.
55
+ #
56
+ # @param attempt [Integer] the 1-based retry attempt
57
+ # @return [Float] seconds
58
+ def schedule(attempt)
59
+ [BASE_DELAY_SECONDS * (2**(attempt - 1)), MAX_DELAY_SECONDS].min
60
+ end
61
+
62
+ private
63
+
64
+ # @return [Boolean] false only when the conflict is on the aggregate's own stream and the
65
+ # read model already reports at least the stream revision the conflict was raised with
66
+ def worth_waiting?(error, aggregate_id, read_model_revision)
67
+ return true unless own_stream?(error.stream, aggregate_id)
68
+ return false unless read_model_revision.is_a?(Integer) && error.revision.is_a?(Integer)
69
+
70
+ read_model_revision < error.revision
71
+ end
72
+
73
+ # @return [Boolean] true when the stream belongs to the aggregate itself; a stream that
74
+ # cannot be inspected is treated as the aggregate's own
75
+ def own_stream?(stream, aggregate_id)
76
+ return true unless stream.respond_to?(:stream_id)
77
+
78
+ stream.stream_id.to_s == aggregate_id.to_s
79
+ end
80
+
81
+ # @return [Float] seconds already spent sleeping before this attempt
82
+ def waited_before(attempt)
83
+ (1...attempt).sum { schedule(_1) }
84
+ end
85
+
86
+ # @return [Float] the delay randomised by ±JITTER_FRACTION
87
+ def jittered(delay)
88
+ delay * (1 + (JITTER_FRACTION * ((2 * rand) - 1)))
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module CommandHandling
6
+ # Shared by {CommandExecutor} and {CommandGroupExecutor}: the wait between two
7
+ # attempts after a PgEventstore::WrongExpectedRevisionError. Expects the
8
+ # including class to expose a private +aggregate+ reader.
9
+ module RevisionConflictWaiting
10
+ private
11
+
12
+ # Sleeps only while the read model has not yet caught up with the stream
13
+ # revision reported by the conflict, see {RevisionConflictBackoff}.
14
+ #
15
+ # @param error [PgEventstore::WrongExpectedRevisionError]
16
+ # @param attempt [Integer] the 1-based retry attempt about to be made
17
+ # @return [void]
18
+ def wait_for_read_model(error, attempt)
19
+ delay = RevisionConflictBackoff.delay(
20
+ error:, aggregate_id: aggregate.id, read_model_revision: current_read_model_revision, attempt:
21
+ )
22
+ sleep(delay) if delay.positive?
23
+ end
24
+
25
+ # @return [Integer, nil] the freshly reloaded read model revision; nil without a read model or
26
+ # when the row disappeared underneath the retry, both of which mean "retry immediately"
27
+ def current_read_model_revision
28
+ return nil unless aggregate.class.read_model_enabled?
29
+
30
+ aggregate.reload.revision
31
+ rescue ActiveRecord::RecordNotFound
32
+ nil
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -109,10 +109,15 @@ module Yes
109
109
  @commands = build_commands
110
110
  end
111
111
 
112
- # @return [Hash] hash form for serialization, merging normalized payload
113
- # and reserved keys (matches legacy {Yes::Core::Commands::Group#to_h})
112
+ # @return [Hash] hash form for serialization the FLAT input payload
113
+ # merged with reserved keys (transaction/origin/batch_id/metadata/
114
+ # command_id). This shape round-trips cleanly: calling
115
+ # `self.class.new(cmd.to_h)` produces an equivalent group with the
116
+ # same `payload`. Used by
117
+ # {Yes::Core::ActiveJobSerializers::CommandGroupSerializer} and by
118
+ # the Command API controller's `add_metadata`.
114
119
  def to_h
115
- merged = normalized_payload.merge(group_attributes.to_h)
120
+ merged = payload.merge(group_attributes.to_h)
116
121
  transaction ? merged.merge(transaction:) : merged
117
122
  end
118
123
 
@@ -79,14 +79,13 @@ module Yes
79
79
  end
80
80
 
81
81
  # Executes a single command on its aggregate
82
- # @param cmd [Command] the command to execute
83
- # @return [Response] response from executing the command
82
+ # @param cmd [Command, Yes::Core::Commands::CommandGroup] the command to execute
83
+ # @return [Response, Yes::Core::Commands::CommandGroupResponse] response from executing the command
84
84
  def run_command(cmd)
85
85
  command_helper = Yes::Core::Commands::Helper.new(cmd)
86
86
  draft = draft?(cmd)
87
87
  aggregate = aggregate_class(cmd).new(cmd.aggregate_id, draft:)
88
88
  I18n.with_locale(command_helper.command_locale) do
89
- # Pass payload as first argument, guards as option
90
89
  aggregate.public_send(command_helper.command_name, cmd.to_h, guards: !draft)
91
90
  end
92
91
  end
@@ -103,21 +102,43 @@ module Yes
103
102
  end
104
103
 
105
104
  # Checks if a guard evaluator exists for the given command
106
- # @param cmd [Command] The command to check
105
+ #
106
+ # Aggregate-DSL command groups register their guard evaluator under a
107
+ # dedicated registry type, so they are resolved through a different
108
+ # configuration lookup than single commands.
109
+ #
110
+ # @param cmd [Command, Yes::Core::Commands::CommandGroup] The command to check
107
111
  # @return [Boolean] true if a guard evaluator exists
108
112
  # @raise [UnregisteredCommand] if no guard evaluator is found for the command
109
113
  def guard_evaluator_exists?(cmd)
110
114
  command_helper = Yes::Core::Commands::Helper.new(cmd)
111
115
 
112
- klass = Yes::Core.configuration.guard_evaluator_class(command_helper.command_context,
113
- command_helper.subject,
114
- command_helper.command_name)
116
+ klass = guard_evaluator_class_for(cmd, command_helper)
115
117
 
116
118
  raise UnregisteredCommand, "Unregistered command: #{cmd.class}" unless klass
117
119
 
118
120
  true
119
121
  end
120
122
 
123
+ # Resolves the guard evaluator class for a command, dispatching to the
124
+ # command-group registry for {Yes::Core::Commands::CommandGroup} and to
125
+ # the single-command registry otherwise.
126
+ #
127
+ # @param cmd [Command, Yes::Core::Commands::CommandGroup] The command
128
+ # @param command_helper [Yes::Core::Commands::Helper] helper for the command
129
+ # @return [Class, nil] the registered guard evaluator class or nil
130
+ def guard_evaluator_class_for(cmd, command_helper)
131
+ if cmd.is_a?(Yes::Core::Commands::CommandGroup)
132
+ Yes::Core.configuration.command_group_guard_evaluator_class(command_helper.command_context,
133
+ command_helper.subject,
134
+ command_helper.command_name)
135
+ else
136
+ Yes::Core.configuration.guard_evaluator_class(command_helper.command_context,
137
+ command_helper.subject,
138
+ command_helper.command_name)
139
+ end
140
+ end
141
+
121
142
  # Ensures handlers exist for all commands
122
143
  # @param commands [Array<Command>] The commands to check
123
144
  # @return [Boolean] true if handlers exist for all commands
@@ -118,7 +118,8 @@ module Yes
118
118
  PgEventstore.client.append_to_stream(
119
119
  stream,
120
120
  event,
121
- options: { expected_revision: subject_stream_revision }
121
+ options: { expected_revision: subject_stream_revision },
122
+ middlewares: Middlewares.for_write
122
123
  ).tap { otl_record_response(_1) }
123
124
  end
124
125
  otl_trackable :publish_event, OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Publish Event', span_kind: :producer)
@@ -190,20 +191,28 @@ module Yes
190
191
  expected = expected_revision(stream)
191
192
  next if revision == expected
192
193
 
193
- revision_error!(revision || -1, expected || -1, stream)
194
+ revision_error!(expected || -1, revision || -1, stream)
194
195
  end
195
196
  end
196
197
 
197
- # @param revision [Integer]
198
- # @param expected_revision [Integer]
198
+ # Same argument convention as pg_eventstore itself: `revision` is what the store
199
+ # holds, `expected_revision` what this handler held.
200
+ #
201
+ # @param revision [Integer] the stream's current revision
202
+ # @param expected_revision [Integer] the revision the handler expected
199
203
  # @param stream [PgEventstore::Stream]
200
204
  def revision_error!(revision, expected_revision, stream)
201
- PgEventstore::WrongExpectedRevisionError.new(revision:, expected_revision:, stream:).tap do |error|
205
+ # pg_eventstore 3.0 requires `verdict:`, which selects the error's
206
+ # message. verify_revisions! only calls this when the two revisions
207
+ # differ, which is exactly :unmatched_stream_revision.
208
+ PgEventstore::WrongExpectedRevisionError.new(
209
+ revision:, expected_revision:, stream:, verdict: :unmatched_stream_revision
210
+ ).tap do |error|
202
211
  self.class.current_span&.status = ::OpenTelemetry::Trace::Status.error('Wrong expected revision')
203
212
  self.class.current_span&.add_attributes(
204
213
  {
205
214
  current_revision: revision,
206
- expected_revision: expected_revision,
215
+ expected_revision:,
207
216
  stream: stream.to_json
208
217
  }.stringify_keys
209
218
  )
@@ -277,7 +286,7 @@ module Yes
277
286
  timestamp: result.created_at,
278
287
  attributes: {
279
288
  'event.type' => result.type,
280
- 'event.link_id' => result.link_id || '',
289
+ 'event.link_global_position' => result.link_global_position || '',
281
290
  'global_position' => result.global_position,
282
291
  'stream' => result.stream.to_json,
283
292
  'stream.revision' => result.stream_revision,
@@ -287,7 +287,7 @@ module Yes
287
287
  # @return [Enumerator]
288
288
  def load_events(stream, options: {}, skip_decryption: true)
289
289
  options = { direction: 'Backwards' }.merge(options)
290
- middlewares = Middlewares.without(:encryptor) if skip_decryption
290
+ middlewares = Middlewares.without(Middlewares::ENCRYPTOR) if skip_decryption
291
291
  PgEventstore.client.read_paginated(stream, options:, middlewares:)
292
292
  end
293
293
 
@@ -331,6 +331,23 @@ module Yes
331
331
  aggregate_class(context_name, aggregate_name, command_name.to_s.underscore.to_sym, :guard_evaluator)
332
332
  end
333
333
 
334
+ # Retrieve a guard evaluator class for a specific command group.
335
+ #
336
+ # Aggregate-DSL command groups register their guard evaluator under the
337
+ # dedicated `:command_group_guard_evaluator` type (see
338
+ # {#register_command_group_guard_evaluator_class}), not `:guard_evaluator`,
339
+ # so a group cannot be resolved through {#guard_evaluator_class}.
340
+ #
341
+ # @param context_name [Symbol, String] The context for the aggregate
342
+ # @param aggregate_name [Symbol, String] The name of the aggregate
343
+ # @param group_name [Symbol, String] The name of the command group
344
+ # @return [Class, nil] The registered guard evaluator class or nil if not found
345
+ # @example
346
+ # evaluator = command_group_guard_evaluator_class(:companies, :apprenticeship, :create_apprenticeship)
347
+ def command_group_guard_evaluator_class(context_name, aggregate_name, group_name)
348
+ aggregate_class(context_name, aggregate_name, group_name.to_s.underscore.to_sym, :command_group_guard_evaluator)
349
+ end
350
+
334
351
  # List all registered classes across all aggregates and contexts
335
352
  # @return [Hash] A complete hash of all registered classes
336
353
  # @example
@@ -40,13 +40,13 @@ module Yes
40
40
  def decrypt_attributes(key:, data:, attributes: {}) # rubocop:disable Lint/UnusedMethodArgument
41
41
  return data unless key
42
42
 
43
- res = key_repository.decrypt(key:, message: data['es_encrypted'])
43
+ res = key_repository.decrypt(key:, message: data[DataEncryptor::CIPHERTEXT_KEY])
44
44
  return data if res.failure?
45
45
 
46
46
  decrypted_text = res.value!
47
47
  decrypted = JSON.parse(decrypted_text.attributes[:message]).transform_keys(&:to_s)
48
48
  decrypted.each { |k, value| data[k] = value if data.key?(k) }
49
- data.delete('es_encrypted')
49
+ data.delete(DataEncryptor::CIPHERTEXT_KEY)
50
50
  data
51
51
  end
52
52
 
@@ -10,6 +10,10 @@ module Yes
10
10
  # encryptor.encrypted_data
11
11
  # encryptor.encryption_metadata
12
12
  class DataEncryptor
13
+ # Data key holding the ciphertext of all encrypted attributes. Its presence means the data is
14
+ # currently encrypted: it is written here and removed by {DataDecryptor}.
15
+ CIPHERTEXT_KEY = 'es_encrypted'
16
+
13
17
  # @return [Hash] the encrypted data
14
18
  attr_reader :encrypted_data
15
19
 
@@ -51,8 +55,8 @@ module Yes
51
55
  def encrypt_attributes(key:, data:, attributes:)
52
56
  text = JSON.generate(data.select { |hash_key, _value| attributes.include?(hash_key.to_s) })
53
57
  encrypted = key_repository.encrypt(key:, message: text).value!
54
- attributes.each { |att| data[att.to_s] = 'es_encrypted' if data.key?(att.to_s) }
55
- data['es_encrypted'] = encrypted.attributes[:message]
58
+ attributes.each { |att| data[att.to_s] = CIPHERTEXT_KEY if data.key?(att.to_s) }
59
+ data[CIPHERTEXT_KEY] = encrypted.attributes[:message]
56
60
  data
57
61
  end
58
62
  end
@@ -2,9 +2,28 @@
2
2
 
3
3
  module Yes
4
4
  module Core
5
+ # Base class for the gem's errors, carrying optional caller-supplied context.
6
+ #
7
+ # @example Raising with context
8
+ # raise Yes::Core::Error.new('could not resolve the aggregate', extra: { id: })
9
+ #
10
+ # @example Consuming the context safely
11
+ # # #extra is not guaranteed to be a Hash - type-check before merging it.
12
+ # payload.merge!(error.extra) if error.extra.is_a?(Hash)
5
13
  class Error < StandardError
14
+ # Caller-supplied context, returned exactly as it was given.
15
+ #
16
+ # It defaults to +nil+ and is never coerced or validated, so it may be any object the
17
+ # caller passed. Code that treats it as a Hash must therefore type-check first: a bare
18
+ # +payload.merge!(error.extra)+ raises +TypeError+ both for the +nil+ default and for any
19
+ # other non-Hash value. That matters most inside error-reporting hooks, where such a
20
+ # +TypeError+ tends to be swallowed by the reporter and takes the report down with it.
21
+ #
22
+ # @return [Object, nil] whatever the caller supplied; +nil+ when nothing was
6
23
  attr_reader :extra
7
24
 
25
+ # @param message [String, nil] the error message
26
+ # @param extra [Object, nil] arbitrary context to attach; stored as given
8
27
  def initialize(message = nil, extra: nil)
9
28
  super(message)
10
29
  @extra = extra
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ # Reports a permanently dead pg_eventstore subscription to Sentry.
6
+ #
7
+ # pg_eventstore calls its +failed_subscription_notifier+ exactly once, when a
8
+ # subscription exhausts its restarts and stays dead — it is the gem's only
9
+ # death signal, and without it that death is silent. Per-failure
10
+ # errors are only recorded on the subscription row, never raised into Sentry.
11
+ class FailedSubscriptionNotifier
12
+ # @param subscription [PgEventstore::Subscription]
13
+ # @param error [StandardError]
14
+ # @return [void]
15
+ def call(subscription, error)
16
+ Sentry.with_scope do |scope|
17
+ scope.set_tags(failed_subscription_notifier: true) # used in Sentry Alerts
18
+
19
+ Sentry.capture_exception(
20
+ error,
21
+ # a death report must never be swallowed by Sentry's excluded_exceptions
22
+ hint: { ignore_exclusions: true },
23
+ extra: { id: subscription.id, set: subscription.set, name: subscription.name }
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -5,9 +5,12 @@ module Yes
5
5
  module Middlewares
6
6
  # PgEventstore middleware for encrypting/decrypting event data.
7
7
  #
8
+ # Register it through {Middlewares.register_encryptor} rather than by hand, so its write-only
9
+ # counterpart ({WriteEncryptor}) is always registered alongside it.
10
+ #
8
11
  # @example
9
12
  # PgEventstore.configure do |config|
10
- # config.middlewares[:encryptor] = Yes::Core::Middlewares::Encryptor.new(key_repository)
13
+ # Yes::Core::Middlewares.register_encryptor(key_repository, config:)
11
14
  # end
12
15
  class Encryptor
13
16
  include PgEventstore::Middleware
@@ -24,6 +27,11 @@ module Yes
24
27
  # @return [PgEventstore::Event]
25
28
  def serialize(event)
26
29
  return event unless event.class.respond_to?(:encryption_schema)
30
+ # Idempotence guard. With {WriteEncryptor} registered, the DEFAULT middleware list holds two
31
+ # serialize-capable encryptors, so an append that omits `middlewares:` would encrypt twice. The
32
+ # second pass would encrypt the sentinels written by the first and overwrite the real ciphertext,
33
+ # which cannot be recovered. It also guards re-appending an event that was read at rest.
34
+ return event if event.data[DataEncryptor::CIPHERTEXT_KEY].present?
27
35
 
28
36
  encryptor = DataEncryptor.new(
29
37
  data: event.data, schema: event.class.encryption_schema, repository: key_repository
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Middlewares
6
+ # Encrypts on the way in exactly like {Encryptor}, but does not decrypt on the way out.
7
+ #
8
+ # pg_eventstore 3.0 runs every registered middleware's #deserialize on the events returned by
9
+ # #append_to_stream, not only on the ones returned by reads. Nothing on the write path reads `data` off
10
+ # that returned event, so decrypting it costs two uncached HTTP calls to the encryptor service (a key
11
+ # lookup and a decrypt) per encrypted event, for a payload that is immediately discarded.
12
+ #
13
+ # #serialize is inherited rather than reimplemented on purpose: encryption at rest must never differ
14
+ # between the read and the write variant.
15
+ #
16
+ # Registered alongside {Encryptor} by {Middlewares.register_encryptor} and selected by
17
+ # {Middlewares.for_write}. The read path keeps using {Encryptor}, unchanged.
18
+ #
19
+ # @example
20
+ # PgEventstore.client.append_to_stream(stream, event, middlewares: Yes::Core::Middlewares.for_write)
21
+ class WriteEncryptor < Encryptor
22
+ # Deliberately does nothing: the caller does not read the returned event's data.
23
+ #
24
+ # Returns the event rather than nil. pg_eventstore itself ignores the return value, but middlewares
25
+ # are also invoked directly in places that use it.
26
+ #
27
+ # @param event [PgEventstore::Event]
28
+ # @return [PgEventstore::Event]
29
+ def deserialize(event)
30
+ event
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -3,9 +3,54 @@
3
3
  module Yes
4
4
  module Core
5
5
  module Middlewares
6
+ # Config key of the encryptor used on read paths: encrypts on #serialize, decrypts on #deserialize.
7
+ ENCRYPTOR = :encryptor
8
+ # Config key of the encryptor used on write paths: encrypts on #serialize, no-op on #deserialize.
9
+ WRITE_ENCRYPTOR = :write_encryptor
10
+
6
11
  class << self
12
+ # Registers both encryptor middlewares against the same key repository.
13
+ #
14
+ # Always use this instead of assigning config.middlewares[:encryptor] by hand: registering the
15
+ # decrypting encryptor without its write-only twin silently doubles the encryptor round trips every
16
+ # encrypted append performs (see {WriteEncryptor}).
17
+ #
18
+ # Mutates the given config in place rather than opening its own PgEventstore.configure block, because
19
+ # PgEventstore.configure takes a non-reentrant mutex - nesting one inside another deadlocks.
20
+ #
21
+ # @param key_repository [#find, #create, #encrypt, #decrypt]
22
+ # @param config [PgEventstore::Config] the config yielded by PgEventstore.configure
23
+ # @return [void]
24
+ def register_encryptor(key_repository, config: PgEventstore.config)
25
+ config.middlewares[ENCRYPTOR] = Encryptor.new(key_repository)
26
+ config.middlewares[WRITE_ENCRYPTOR] = WriteEncryptor.new(key_repository)
27
+ end
28
+
29
+ # Middleware keys to pass to #append_to_stream: every configured middleware, with the decrypting
30
+ # encryptor swapped for the write-only one.
31
+ #
32
+ # Derived from the live config, never hard-coded. PgEventstore::Client resolves a passed list with
33
+ # `config.middlewares.slice(*list)`, which silently drops names that are not registered - so a literal
34
+ # list would resolve to one with NO encryptor at all against a config that registered it under a
35
+ # different key, and would write plaintext at rest undetectably. Deriving the list makes that
36
+ # impossible, and picks up any middleware added later for free.
37
+ #
38
+ # Falls back to the full list when {WRITE_ENCRYPTOR} is not registered. That is correct, just as slow
39
+ # as before - unlike a hard-coded list, which would drop encryption altogether.
40
+ #
41
+ # @return [Array<Symbol>]
42
+ def for_write
43
+ keys = PgEventstore.config.middlewares.keys
44
+ return keys unless keys.include?(WRITE_ENCRYPTOR)
45
+
46
+ keys - [ENCRYPTOR]
47
+ end
48
+
7
49
  # Returns middleware keys excluding the specified one.
8
50
  #
51
+ # Note that excluding {ENCRYPTOR} still yields a list containing {WRITE_ENCRYPTOR}, whose #deserialize
52
+ # is a no-op - so `without(:encryptor)` keeps meaning "read the data as it is stored".
53
+ #
9
54
  # @param middleware_name [Symbol] the middleware key to exclude
10
55
  # @return [Array<Symbol>] remaining middleware keys
11
56
  def without(middleware_name)
@@ -16,6 +16,11 @@ module Yes
16
16
  with_indifferent_access: Yes::Core::Middlewares::WithIndifferentAccess.new,
17
17
  timestamp: Yes::Core::Middlewares::Timestamp.new
18
18
  }
19
+
20
+ # pg_eventstore's only death signal — without it a subscription that
21
+ # exhausts its restarts dies silently: per-failure errors are only recorded
22
+ # on the subscription row, never raised.
23
+ config.failed_subscription_notifier = FailedSubscriptionNotifier.new if defined?(Sentry)
19
24
  end
20
25
  end
21
26
 
@@ -49,6 +54,21 @@ module Yes
49
54
  PgEventstore.logger ||= Rails.logger if ENV['PG_ES_LOGGING'] == 'true'
50
55
  end
51
56
 
57
+ # The one misconfiguration Middlewares.for_write cannot fix by itself: a decrypting encryptor registered
58
+ # without its write-only twin. for_write then falls back to the full list, so writes stay CORRECT - they
59
+ # just keep paying an encryptor key lookup and decrypt per encrypted event, for data nobody reads.
60
+ # Warned about once at boot rather than on every append.
61
+ config.after_initialize do
62
+ middlewares = PgEventstore.config.middlewares
63
+ next unless middlewares.key?(Yes::Core::Middlewares::ENCRYPTOR)
64
+ next if middlewares.key?(Yes::Core::Middlewares::WRITE_ENCRYPTOR)
65
+
66
+ Rails.logger.warn(
67
+ 'PgEventstore middleware :encryptor is registered without :write_encryptor, so append_to_stream ' \
68
+ 'decrypts every event it returns. Register both with Yes::Core::Middlewares.register_encryptor.'
69
+ )
70
+ end
71
+
52
72
  # Load aggregate shortcuts when Rails console starts
53
73
  console do
54
74
  Yes::Core::Utils::AggregateShortcuts.load!
@@ -16,7 +16,7 @@ module Yes
16
16
  # @param event [Yes::Core::Event]
17
17
  # @return [void]
18
18
  def append_event(stream, event)
19
- PgEventstore.client.append_to_stream(stream, event)
19
+ PgEventstore.client.append_to_stream(stream, event, middlewares: Yes::Core::Middlewares.for_write)
20
20
  end
21
21
 
22
22
  # Appends an event to a stream and reloads it
@@ -20,7 +20,14 @@ module Yes
20
20
 
21
21
  EMPTY_STRING = /\A\s*\z/
22
22
 
23
- UUID_REGEXP_BASE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}/
23
+ # Version nibble is [1-8], not a hard-coded 4. pg_eventstore 3.0.0 moved event-id
24
+ # generation off the database's gen_random_uuid() (always v4) to
25
+ # SecureRandom.uuid_v7, and those ids reach us as causation_id / correlation_id.
26
+ # A v4-only pattern makes TransactionDetails raise Dry::Struct::Error, which fails
27
+ # the event handler and kills the subscription once its restarts run out.
28
+ #
29
+ # Still a real constraint, per RFC 9562: version 1-8 and variant nibble 8/9/a/b.
30
+ UUID_REGEXP_BASE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}/
24
31
  UUID_REGEXP = /\A#{UUID_REGEXP_BASE}\z/i
25
32
 
26
33
  DATE_TIME_REGEXP = /\A\d{4}-\d{1,2}-\d{1,2} ([0-1][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?\z/i
@@ -22,7 +22,8 @@ module Yes
22
22
  dupl
23
23
  end
24
24
 
25
- # Returns a hash with the keys flattened
25
+ # Returns a hash with the keys flattened. Scalar, nested-hash and
26
+ # array values all honour the `prefix` consistently.
26
27
  #
27
28
  # @param obj [Hash, Array] the object to flatten
28
29
  # @param prefix [String] the key to use as a prefix for the keys in the hash
@@ -32,6 +33,10 @@ module Yes
32
33
  # @example
33
34
  # HashUtils.deep_flatten_hash({ name: 'A', otl_contexts: { root: { attr: 10, available: true } } })
34
35
  # => {"name"=>"A", "otl_contexts.root.attr"=>10, "otl_contexts.root.available"=>true}
36
+ #
37
+ # @example with a prefix (arrays included)
38
+ # HashUtils.deep_flatten_hash({ id: 1, tags: [{ k: 'v' }] }, 'span')
39
+ # => {"span.id"=>1, "span.tags"=>[{"k"=>"v"}]}
35
40
  def deep_flatten_hash(obj, prefix = nil, memo = {})
36
41
  case obj
37
42
  when Hash
@@ -42,7 +47,7 @@ module Yes
42
47
  in String | Symbol, Hash
43
48
  deep_flatten_hash(value, prefix ? "#{prefix}.#{key}" : key.to_s, memo)
44
49
  in String | Symbol, Array
45
- memo[key.to_s] = deep_flatten_hash(value)
50
+ memo[prefix ? "#{prefix}.#{key}" : key.to_s] = deep_flatten_hash(value)
46
51
  in Array, _
47
52
  memo[deep_flatten_hash(key)] = deep_flatten_hash(value)
48
53
  else
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Yes
4
4
  module Core
5
- VERSION = '1.3.0'
5
+ VERSION = '2.3.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yes-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nico Ritsche
@@ -127,14 +127,14 @@ dependencies:
127
127
  requirements:
128
128
  - - "~>"
129
129
  - !ruby/object:Gem::Version
130
- version: '1.0'
130
+ version: '3.0'
131
131
  type: :runtime
132
132
  prerelease: false
133
133
  version_requirements: !ruby/object:Gem::Requirement
134
134
  requirements:
135
135
  - - "~>"
136
136
  - !ruby/object:Gem::Version
137
- version: '1.0'
137
+ version: '3.0'
138
138
  - !ruby/object:Gem::Dependency
139
139
  name: rails
140
140
  requirement: !ruby/object:Gem::Requirement
@@ -224,6 +224,7 @@ files:
224
224
  - lib/yes/core/authorization/cerbos_client_provider.rb
225
225
  - lib/yes/core/authorization/command_authorizer.rb
226
226
  - lib/yes/core/authorization/command_cerbos_authorizer.rb
227
+ - lib/yes/core/authorization/lookup_cache.rb
227
228
  - lib/yes/core/authorization/read_model_authorizer.rb
228
229
  - lib/yes/core/authorization/read_models_authorizer.rb
229
230
  - lib/yes/core/authorization/read_request_authorizer.rb
@@ -241,6 +242,8 @@ files:
241
242
  - lib/yes/core/command_handling/read_model_recovery_service.rb
242
243
  - lib/yes/core/command_handling/read_model_revision_guard.rb
243
244
  - lib/yes/core/command_handling/read_model_updater.rb
245
+ - lib/yes/core/command_handling/revision_conflict_backoff.rb
246
+ - lib/yes/core/command_handling/revision_conflict_waiting.rb
244
247
  - lib/yes/core/command_handling/state_updater.rb
245
248
  - lib/yes/core/commands/bus.rb
246
249
  - lib/yes/core/commands/command_group.rb
@@ -267,6 +270,7 @@ files:
267
270
  - lib/yes/core/error_messages.rb
268
271
  - lib/yes/core/event.rb
269
272
  - lib/yes/core/event_class_resolver.rb
273
+ - lib/yes/core/failed_subscription_notifier.rb
270
274
  - lib/yes/core/generators/read_models/add_pending_update_tracking_generator.rb
271
275
  - lib/yes/core/generators/read_models/templates/add_pending_update_tracking.rb.erb
272
276
  - lib/yes/core/generators/read_models/templates/migration.rb.erb
@@ -276,6 +280,7 @@ files:
276
280
  - lib/yes/core/middlewares/encryptor.rb
277
281
  - lib/yes/core/middlewares/timestamp.rb
278
282
  - lib/yes/core/middlewares/with_indifferent_access.rb
283
+ - lib/yes/core/middlewares/write_encryptor.rb
279
284
  - lib/yes/core/models/application_record.rb
280
285
  - lib/yes/core/open_telemetry/otl_span.rb
281
286
  - lib/yes/core/open_telemetry/trackable.rb