sourced-message 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 31c91f70a00fff9b43bef2a60f506086bac336a5faa52a902c405c9374afe6b5
4
+ data.tar.gz: 2a0923e44fe974832e726ec16d2d57b9c8e09de4351984ff92d62c8da1c4ac9f
5
+ SHA512:
6
+ metadata.gz: 7877198025efae1bd7c6264da63091381dd319e5e84507fdd8f45452f3fc68298f6eafcedb005d78e75491df241c14c7364b826f81d534ecf18e9219f5661b89
7
+ data.tar.gz: da2f6ee90b5d568708a6a5c6b98f2fd856a6df853d35f1c9a9e482d857e7211becf8b7ef3837abaec0bf62c85f7999198977ecb14062f44f3b7bdf48affe79f2
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-06-06
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ismael Celis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # Sourced::Message
2
+
3
+ `Sourced::Message` is a canonical, typed message class for event-driven Ruby systems. It is the shared base used by [Sourced](https://github.com/ismasan/sourced) and Sidereal, but it has no dependency on either and can be used on its own.
4
+
5
+ A message is a [Plumb](https://github.com/ismasan/plumb)-typed value object with:
6
+
7
+ - a stable, human-readable `type` string (e.g. `'course.created'`)
8
+ - a typed, validated `payload`
9
+ - an auto-generated `id` and `created_at` timestamp
10
+ - `causation_id` / `correlation_id` for tracing causal chains across processes
11
+ - arbitrary `metadata`
12
+ - a global **type registry** that can reconstruct any message from a plain hash — handy for transports, queues and event stores
13
+ - scheduling helpers (`#at` / `#in`) for delayed messages
14
+
15
+ Messages are immutable: every "mutating" method (`#with_payload`, `#with_metadata`, `#at`, `#correlate`) returns a copy.
16
+
17
+ ## Installation
18
+
19
+ Install the gem and add it to the application's Gemfile by executing:
20
+
21
+ ```bash
22
+ bundle add sourced-message
23
+ ```
24
+
25
+ If bundler is not being used to manage dependencies, install the gem by executing:
26
+
27
+ ```bash
28
+ gem install sourced-message
29
+ ```
30
+
31
+ Then require it:
32
+
33
+ ```ruby
34
+ require 'sourced/message'
35
+ ```
36
+
37
+ Requires Ruby >= 3.2.
38
+
39
+ ## Usage
40
+
41
+ ### Defining message types
42
+
43
+ Use `.define` with a unique type string and an optional block describing the payload attributes (via Plumb's `attribute` DSL):
44
+
45
+ ```ruby
46
+ CourseCreated = Sourced::Message.define('course.created') do
47
+ attribute :course_name, String
48
+ attribute :seats, Integer
49
+ end
50
+ ```
51
+
52
+ Each defined type is a subclass of `Sourced::Message` and is automatically added to the registry.
53
+
54
+ A message can also be defined with no payload:
55
+
56
+ ```ruby
57
+ PingReceived = Sourced::Message.define('ping.received')
58
+ ```
59
+
60
+ ### Creating messages
61
+
62
+ Pass the payload as a hash. The payload is validated and coerced against the schema you declared:
63
+
64
+ ```ruby
65
+ msg = CourseCreated.new(payload: { course_name: 'Ruby 101', seats: 30 })
66
+
67
+ msg.id # => "5f6e..." (auto-generated UUID)
68
+ msg.type # => "course.created"
69
+ msg.created_at # => 2026-06-06 12:00:00 ... (defaults to Time.now)
70
+ msg.metadata # => {}
71
+ msg.causation_id # => same as msg.id by default
72
+ msg.correlation_id # => same as msg.id by default
73
+ ```
74
+
75
+ ### Reading the payload
76
+
77
+ The payload is a typed object. Access attributes by method, by `[]`, or with `fetch`:
78
+
79
+ ```ruby
80
+ msg.payload.course_name # => "Ruby 101"
81
+ msg.payload[:seats] # => 30
82
+ msg.payload.fetch(:seats) # => 30
83
+ msg.payload.fetch(:missing) # => raises KeyError
84
+ ```
85
+
86
+ ### Commands and events
87
+
88
+ `Sourced::Command` and `Sourced::Event` are ready-made subclasses. Define types on them the same way — they register their own types, all visible from the root registry:
89
+
90
+ ```ruby
91
+ EnrollStudent = Sourced::Command.define('student.enroll') do
92
+ attribute :student_id, String
93
+ end
94
+
95
+ StudentEnrolled = Sourced::Event.define('student.enrolled') do
96
+ attribute :student_id, String
97
+ end
98
+ ```
99
+
100
+ ### The registry and `.from`
101
+
102
+ Every defined type lives in a single registry rooted at `Sourced::Message`. This lets you reconstruct the correct subclass from a plain hash that carries a `:type` key — for example when reading messages off a queue, a database, or an HTTP request:
103
+
104
+ ```ruby
105
+ hash = { type: 'course.created', payload: { course_name: 'Ruby 101', seats: 30 } }
106
+
107
+ msg = Sourced::Message.from(hash)
108
+ msg.class # => CourseCreated
109
+ msg.payload.course_name # => "Ruby 101"
110
+ ```
111
+
112
+ Resolving from the root `Sourced::Message` finds types registered under any subclass (`Command`, `Event`, or your own):
113
+
114
+ ```ruby
115
+ Sourced::Message.from(type: 'student.enroll', payload: { student_id: '42' }).class
116
+ # => EnrollStudent
117
+
118
+ Sourced::Message.from(type: 'unknown.type')
119
+ # => raises Sourced::Message::UnknownMessageError
120
+ ```
121
+
122
+ Inspect what's registered:
123
+
124
+ ```ruby
125
+ Sourced::Message.registry.keys # => ["course.created", "student.enroll", ...]
126
+ Sourced::Message.registry.all.to_a # => [CourseCreated, EnrollStudent, ...]
127
+ Sourced::Message.registry['course.created'] # => CourseCreated
128
+ ```
129
+
130
+ ### Copying with changes
131
+
132
+ Messages are immutable. Use the `#with_*` helpers to derive new copies:
133
+
134
+ ```ruby
135
+ # Merge new metadata (keeps the same id)
136
+ tagged = msg.with_metadata(channel: 'web', user_id: '42')
137
+ tagged.metadata # => { channel: 'web', user_id: '42' }
138
+
139
+ # Override payload attributes
140
+ updated = msg.with_payload(seats: 25)
141
+ updated.payload.seats # => 25
142
+ updated.payload.course_name # => "Ruby 101" (unchanged)
143
+ ```
144
+
145
+ ### Correlation: tracing causal chains
146
+
147
+ `#correlate` links one message as the cause of another. It returns a copy of the target with `causation_id` set to the source's `id` and `correlation_id` propagated from the source. Metadata from both messages is merged.
148
+
149
+ ```ruby
150
+ trigger = EnrollStudent.new(payload: { student_id: '42' })
151
+ result = StudentEnrolled.new(payload: { student_id: '42' })
152
+
153
+ caused = trigger.correlate(result)
154
+ caused.causation_id # => trigger.id
155
+ caused.correlation_id # => trigger.correlation_id
156
+ ```
157
+
158
+ This makes it possible to follow a chain of messages across process boundaries: all messages descending from the same originating message share a `correlation_id`, while `causation_id` records the direct parent.
159
+
160
+ ### Scheduling: delayed messages
161
+
162
+ `#at` (aliased as `#in`) returns a copy with `created_at` set to a future instant. It accepts three forms:
163
+
164
+ ```ruby
165
+ # An absolute Time / DateTime
166
+ msg.at(Time.now + 3600)
167
+
168
+ # An Integer number of seconds from now
169
+ msg.in(60)
170
+
171
+ # A Fugit / ISO8601 duration string
172
+ msg.in('5m')
173
+ msg.in('1h30m')
174
+ msg.in('PT1H30M')
175
+ ```
176
+
177
+ Scheduling a message into the past raises `Sourced::Message::PastMessageDateError`:
178
+
179
+ ```ruby
180
+ msg.at(Time.now - 60) # => raises Sourced::Message::PastMessageDateError
181
+ ```
182
+
183
+ Passing a string that isn't a duration (e.g. an absolute date) raises `ArgumentError`:
184
+
185
+ ```ruby
186
+ msg.in('2026-12-31T10:00:00') # => raises ArgumentError
187
+ ```
188
+
189
+ ### Pattern matching with `case`/`when`
190
+
191
+ `Sourced::Message.===` is transparent to wrappers that implement `#to_message`, so messages match correctly in `case`/`when` even when wrapped (e.g. by a positioned/persisted envelope):
192
+
193
+ ```ruby
194
+ case message
195
+ when CourseCreated then handle_course_created(message)
196
+ when StudentEnrolled then handle_student_enrolled(message)
197
+ end
198
+ ```
199
+
200
+ ## Development
201
+
202
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
203
+
204
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the `VERSION` constant in `lib/sourced/message.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
205
+
206
+ ## Contributing
207
+
208
+ Bug reports and pull requests are welcome on GitHub at https://github.com/ismasan/sourced-message.
209
+
210
+ ## License
211
+
212
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'plumb'
5
+ require 'fugit'
6
+
7
+ module Sourced
8
+ # Canonical message class, shared by Sourced and Sidereal.
9
+ #
10
+ # A message has no +stream_id+ or +seq+ — it goes into a flat, globally-ordered
11
+ # log. Supports +causation_id+ / +correlation_id+ for tracing causal chains.
12
+ #
13
+ # Define message types via {.define}:
14
+ #
15
+ # CourseCreated = Sourced::Message.define('course.created') do
16
+ # attribute :course_name, String
17
+ # end
18
+ #
19
+ # Subclasses (e.g. {Sourced::Command}, {Sourced::Event}, +Sidereal::Message+)
20
+ # all share one **top-level registry rooted at {Sourced::Message}**:
21
+ # {Registry#[]} recurses downward into subclass registries, so
22
+ # +Sourced::Message.registry[type]+ resolves a type registered under any
23
+ # subclass. Resolve from this root to see the whole tree.
24
+ class Message < Plumb::Types::Data
25
+ VERSION = '0.1.0'
26
+
27
+ EMPTY_ARRAY = [].freeze
28
+
29
+ # Plumb types used to define the message attributes. Nested under the class
30
+ # so it never collides with the +Sourced::Types+ (sourced gem) or
31
+ # +Sidereal::Types+ modules.
32
+ module Types
33
+ include Plumb::Types
34
+
35
+ # Accepts a UUID string or generates a new one when none is provided.
36
+ AutoUUID = UUID::V4.default { SecureRandom.uuid }
37
+ end
38
+
39
+ # Raised by {.from} when a type string isn't registered.
40
+ UnknownMessageError = Class.new(ArgumentError)
41
+ # Raised by {#at} when a message would be scheduled in the past.
42
+ PastMessageDateError = Class.new(ArgumentError)
43
+
44
+ attribute :id, Types::AutoUUID
45
+ attribute :type, Types::String.present
46
+ attribute? :causation_id, Types::UUID::V4
47
+ attribute? :correlation_id, Types::UUID::V4
48
+ attribute :created_at, Types::Forms::Time.default { Time.now }
49
+ attribute :metadata, Types::Hash.default(Plumb::BLANK_HASH)
50
+ attribute :payload, Types::Static[nil]
51
+
52
+ # Lookup table mapping type strings to message subclasses.
53
+ class Registry
54
+ # @param message_class [Class] the root message class for this registry
55
+ def initialize(message_class)
56
+ @message_class = message_class
57
+ @lookup = {}
58
+ end
59
+
60
+ # @return [Array<String>] registered type strings
61
+ def keys = @lookup.keys
62
+
63
+ # @return [Array<Class>] direct subclasses of the root message class
64
+ def subclasses = message_class.subclasses
65
+
66
+ # Register a message class under a type string.
67
+ #
68
+ # @param key [String] message type string
69
+ # @param klass [Class] message subclass
70
+ def []=(key, klass)
71
+ @lookup[key] = klass
72
+ end
73
+
74
+ # Look up a message class by type string.
75
+ # Searches this registry first, then recurses into subclass registries.
76
+ #
77
+ # @param key [String] message type string
78
+ # @return [Class, nil]
79
+ def [](key)
80
+ klass = lookup[key]
81
+ return klass if klass
82
+
83
+ subclasses.each do |c|
84
+ klass = c.registry[key]
85
+ return klass if klass
86
+ end
87
+ nil
88
+ end
89
+
90
+ # All registered message classes across this registry and subclass registries.
91
+ #
92
+ # @return [Enumerator<Class>] if no block given
93
+ # @yield [Class] each registered message class
94
+ def all(&block)
95
+ return enum_for(:all) unless block
96
+
97
+ lookup.each_value(&block)
98
+ subclasses.each { |c| c.registry.all(&block) }
99
+ end
100
+
101
+ private
102
+
103
+ attr_reader :lookup, :message_class
104
+ end
105
+
106
+ # @return [Registry] the message type registry for this class
107
+ def self.registry
108
+ @registry ||= Registry.new(self)
109
+ end
110
+
111
+ # Base class for typed message payloads.
112
+ class Payload < Plumb::Types::Data
113
+ # @param key [Symbol] attribute name
114
+ # @return [Object] attribute value
115
+ def [](key) = attributes[key]
116
+
117
+ # @see Hash#fetch
118
+ def fetch(...) = to_h.fetch(...)
119
+ end
120
+
121
+ # Define a new message type. Registers it in the {.registry} and
122
+ # optionally defines a typed payload.
123
+ #
124
+ # @param type_str [String] unique message type identifier (e.g. 'course.created')
125
+ # @yield optional block to define payload attributes via +attribute+ DSL
126
+ # @return [Class] the new message subclass
127
+ #
128
+ # @example
129
+ # UserJoined = Sourced::Message.define('user.joined') do
130
+ # attribute :course_name, String
131
+ # attribute :user_id, String
132
+ # end
133
+ def self.define(type_str, &payload_block)
134
+ type_str.freeze unless type_str.frozen?
135
+
136
+ registry[type_str] = Class.new(self) do
137
+ def self.node_name = :data
138
+ define_singleton_method(:type) { type_str }
139
+
140
+ attribute :type, Types::Static[type_str]
141
+ if block_given?
142
+ payload_class = Class.new(Payload, &payload_block)
143
+ const_set(:Payload, payload_class)
144
+ attribute :payload, payload_class
145
+ names = payload_class._schema.to_h.keys.map(&:to_sym).freeze
146
+ define_singleton_method(:payload_attribute_names) { names }
147
+ end
148
+ end
149
+ end
150
+
151
+ # Instantiate the correct message subclass from a hash with a +:type+ key.
152
+ #
153
+ # Resolve from the root ({Sourced::Message}) to see types registered under
154
+ # any subclass — that's how a cross-process transport reconstructs both
155
+ # Sourced and Sidereal message types from one registry.
156
+ #
157
+ # @param attrs [Hash] must include +:type+ matching a registered type string
158
+ # @return [Message] instance of the appropriate subclass
159
+ # @raise [UnknownMessageError] if the type string is not registered
160
+ def self.from(attrs)
161
+ klass = registry[attrs[:type]]
162
+ raise UnknownMessageError, "Unknown message type: #{attrs[:type]}" unless klass
163
+
164
+ klass.new(attrs)
165
+ end
166
+
167
+ def initialize(attrs = {})
168
+ attrs = attrs.merge(payload: {}) unless attrs[:payload]
169
+ super(attrs)
170
+ end
171
+
172
+ # Identity implementation of the +to_message+ contract — see {.===} and any
173
+ # wrapper (e.g. +Sourced::PositionedMessage#to_message+).
174
+ def to_message = self
175
+
176
+ # Make +case/when+ transparent to a wrapper implementing +#to_message+.
177
+ # Ruby's default +Module#===+ is implemented in C and ignores +is_a?+
178
+ # overrides, so wrapped messages would otherwise fall through.
179
+ def self.===(other)
180
+ return true if super
181
+ return false unless other.respond_to?(:to_message)
182
+
183
+ unwrapped = other.to_message
184
+ !unwrapped.equal?(other) && super(unwrapped)
185
+ end
186
+
187
+ def with_metadata(meta = {})
188
+ return self if meta.empty?
189
+
190
+ with(metadata: metadata.merge(meta))
191
+ end
192
+
193
+ def with_payload(attrs = {})
194
+ hash = to_h
195
+ (hash[:payload] ||= {}).merge!(attrs)
196
+ self.class.new(hash)
197
+ end
198
+
199
+ # Return a copy with +created_at+ set to a future instant. Three
200
+ # accepted forms:
201
+ #
202
+ # - +Time+ / +DateTime+ / anything with +<+ — used as the absolute
203
+ # target.
204
+ # - +Integer+ — interpreted as seconds; added to +Time.now+.
205
+ # - +String+ — parsed via +Fugit.parse_duration+ as a duration (e.g.
206
+ # +'5m'+, +'1h30m'+, +'PT5M'+) and added to +Time.now+.
207
+ #
208
+ # Raises {PastMessageDateError} when the resolved target is
209
+ # before +created_at+.
210
+ def at(value)
211
+ target = case value
212
+ when Integer
213
+ Time.now + value
214
+ when String
215
+ parsed = Fugit.parse_duration(value) or
216
+ raise ArgumentError,
217
+ "Message#at: String argument must be an ISO8601 / Fugit duration " \
218
+ "(e.g. '5m', 'PT1H30M'); got #{value.inspect}"
219
+ parsed.add_to_time(Time.now).to_local_time
220
+ else
221
+ value
222
+ end
223
+
224
+ if target < created_at
225
+ raise PastMessageDateError, "Message #{type} can't be delayed to a date in the past"
226
+ end
227
+
228
+ with(created_at: target)
229
+ end
230
+
231
+ alias in at
232
+
233
+ # Set causation and correlation IDs on another message, establishing
234
+ # a causal link from this message to +message+. Merges metadata.
235
+ #
236
+ # @param message [Message] the message to correlate
237
+ # @return [Message] a copy of +message+ with causation/correlation set
238
+ #
239
+ # @example
240
+ # caused = source_event.correlate(SomeCommand.new(payload: { ... }))
241
+ # caused.causation_id # => source_event.id
242
+ # caused.correlation_id # => source_event.correlation_id
243
+ def correlate(message)
244
+ attrs = {
245
+ causation_id: id,
246
+ correlation_id: correlation_id,
247
+ metadata: metadata.merge(message.metadata || Plumb::BLANK_HASH)
248
+ }
249
+ message.with(attrs)
250
+ end
251
+
252
+ # Returns the declared payload attribute names for this message class.
253
+ # Subclasses created via {.define} override this with a cached frozen array.
254
+ #
255
+ # @return [Array<Symbol>] attribute names (e.g. +[:course_name, :user_id]+)
256
+ def self.payload_attribute_names = EMPTY_ARRAY
257
+
258
+ private
259
+
260
+ # Hook called by Plumb after schema parsing, when +:id+ has been resolved.
261
+ # Defaults +causation_id+ and +correlation_id+ to the message's own +id+.
262
+ def prepare_attributes(attrs)
263
+ attrs[:correlation_id] = attrs[:id] unless attrs[:correlation_id]
264
+ attrs[:causation_id] = attrs[:id] unless attrs[:causation_id]
265
+ attrs
266
+ end
267
+ end
268
+
269
+ class Command < Message; end
270
+ class Event < Message; end
271
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sourced-message
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ismael Celis
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: plumb
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.0.17
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.0.17
26
+ - !ruby/object:Gem::Dependency
27
+ name: fugit
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: Provides Sourced::Message — a Plumb-typed message with a shared type
41
+ registry, payloads, correlation, and scheduling — used as the common base for Sourced
42
+ and Sidereal messages.
43
+ email:
44
+ - ismaelct@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE.txt
51
+ - README.md
52
+ - Rakefile
53
+ - lib/sourced/message.rb
54
+ homepage: https://github.com/ismasan/sourced-message
55
+ licenses:
56
+ - MIT
57
+ metadata:
58
+ homepage_uri: https://github.com/ismasan/sourced-message
59
+ source_code_uri: https://github.com/ismasan/sourced-message
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 3.2.0
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 4.0.8
75
+ specification_version: 4
76
+ summary: Canonical typed Message class and registry shared by Sourced and Sidereal.
77
+ test_files: []