custodian-core 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.
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Custodian
4
+ module Core
5
+ module ActionRegistry
6
+ class AlreadyRegisteredError < Custodian::Core::Error; end
7
+ class NotRegisteredError < Custodian::Core::Error; end
8
+ class InvalidOutcomeError < Custodian::Core::Error; end
9
+
10
+ VALID_SYMBOL_OUTCOMES = %i[resolved failed].freeze
11
+
12
+ @registry = {}
13
+ @mutex = Mutex.new
14
+
15
+ class << self
16
+ def register(action_name, &block)
17
+ name = action_name.to_sym
18
+ @mutex.synchronize do
19
+ raise AlreadyRegisteredError, "action #{name.inspect} is already registered" if @registry.key?(name)
20
+
21
+ @registry[name] = block
22
+ end
23
+ end
24
+
25
+ def call(action_name, node, custody, remaining)
26
+ name = action_name.to_sym
27
+ block = @mutex.synchronize { @registry[name] }
28
+ raise NotRegisteredError, "action #{name.inspect} is not registered" unless block
29
+
30
+ outcome = block.call(node, custody, remaining)
31
+ validate_outcome!(name, outcome, remaining: remaining)
32
+ outcome
33
+ end
34
+
35
+ def unregister(action_name)
36
+ name = action_name.to_sym
37
+ @mutex.synchronize do
38
+ raise NotRegisteredError, "action #{name.inspect} is not registered" unless @registry.key?(name)
39
+
40
+ @registry.delete(name)
41
+ end
42
+ end
43
+
44
+ def registered?(action_name)
45
+ @mutex.synchronize { @registry.key?(action_name.to_sym) }
46
+ end
47
+
48
+ # Removes all registrations. Intended for test isolation: call this
49
+ # in a before/around hook so each spec starts from a clean registry.
50
+ def clear!
51
+ @mutex.synchronize { @registry.clear }
52
+ end
53
+
54
+ # Public so callers outside the registry (e.g. Adjuster's phase
55
+ # hook) can validate an outcome using the exact same rule that
56
+ # governs actions invoked through #call: :resolved, :failed, or a
57
+ # non-negative Numeric.
58
+ def validate_outcome!(name, outcome, remaining: nil)
59
+ return if VALID_SYMBOL_OUTCOMES.include?(outcome)
60
+
61
+ validate_numeric_outcome!(name, outcome, remaining) if outcome.is_a?(Numeric)
62
+ return if outcome.is_a?(Numeric)
63
+
64
+ raise InvalidOutcomeError,
65
+ "action #{name.inspect} returned an invalid outcome: #{outcome.inspect} " \
66
+ "(expected :resolved, :failed, or a Numeric)"
67
+ end
68
+
69
+ private
70
+
71
+ def validate_numeric_outcome!(name, outcome, remaining)
72
+ validate_finite_outcome!(name, outcome)
73
+ validate_non_negative_outcome!(name, outcome)
74
+ return if remaining.nil? || outcome <= remaining
75
+
76
+ raise InvalidOutcomeError,
77
+ "action #{name.inspect} returned #{outcome.inspect}, exceeding remaining demand #{remaining.inspect}"
78
+ rescue ArgumentError, NoMethodError
79
+ raise InvalidOutcomeError,
80
+ "action #{name.inspect} returned an incompatible Numeric outcome: #{outcome.inspect}"
81
+ end
82
+
83
+ def validate_finite_outcome!(name, outcome)
84
+ return if outcome.finite?
85
+
86
+ raise InvalidOutcomeError,
87
+ "action #{name.inspect} returned a non-finite Numeric outcome: #{outcome.inspect}"
88
+ end
89
+
90
+ def validate_non_negative_outcome!(name, outcome)
91
+ return if outcome >= 0
92
+
93
+ raise InvalidOutcomeError,
94
+ "action #{name.inspect} returned a negative Numeric outcome: #{outcome.inspect} " \
95
+ "(an action must not increase demand; Numeric outcomes must be >= 0)"
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,375 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Custodian
6
+ module Core
7
+ module Adjuster
8
+ VALID_STRICTNESS_VALUES = %i[valid trustworthy].freeze
9
+
10
+ @phases = []
11
+ @phases_mutex = Mutex.new
12
+
13
+ class << self
14
+ # Registers a resolution phase, run (in registration order) between
15
+ # direct custodies and escalation for every node still unresolved.
16
+ # This is the extension point a future sibling-generosity satellite
17
+ # plugs into; this step only builds the hook, not any phase itself.
18
+ def register_phase(name, handler)
19
+ @phases_mutex.synchronize do
20
+ @phases << { name: name, handler: handler }.freeze
21
+ end
22
+ end
23
+
24
+ # Removes all registered phases. Intended for test isolation.
25
+ def clear_phases!
26
+ @phases_mutex.synchronize { @phases = [] }
27
+ end
28
+
29
+ # Walks root_node's subtree post-order (leaves first), invoking
30
+ # registered actions through each node's custodies, and escalating
31
+ # unresolved demand to the direct parent (see the class docs for the
32
+ # full encapsulation design). Pure resolution: never persists or
33
+ # mutates any Node/Custody record. Actions themselves may have side
34
+ # effects; that is their business, not the Adjuster's.
35
+ def resolve_tree(root_node, strictness: :valid)
36
+ validate_strictness!(strictness)
37
+ state = build_resolution_state(root_node, strictness)
38
+
39
+ post_order(root_node, state[:children_by_parent_id]) do |node|
40
+ resolve_and_escalate(node, state)
41
+ end
42
+
43
+ settle_numeric_escalations!(state[:results], state[:parent_id_by_node_id], root_node.id)
44
+ state[:results]
45
+ end
46
+
47
+ # Computes, for every node in root_node's subtree, its consolidated
48
+ # demand (own demand_value + aggregated demand_value of numeric
49
+ # descendants). Pure calculation: no persistence, no ActionRegistry
50
+ # calls, no mutation of any record.
51
+ #
52
+ # Loads the whole subtree in a single query, then works entirely in
53
+ # memory (children are grouped by parent_id parsed from the ancestry
54
+ # column, never via DB-querying association methods) so the query
55
+ # count does not scale with tree size.
56
+ def aggregate_demand(root_node)
57
+ nodes = root_node.subtree.to_a
58
+ children_by_parent_id = nodes.group_by { |node| parent_id_of(node) }
59
+
60
+ results = {}
61
+ post_order(root_node, children_by_parent_id) do |node|
62
+ results[node.id] = compute(node, children_by_parent_id[node.id] || [], results)
63
+ end
64
+ results
65
+ end
66
+
67
+ private
68
+
69
+ def parent_id_of(node)
70
+ return nil if node.ancestry.nil?
71
+
72
+ node.ancestry.split("/").last.to_i
73
+ end
74
+
75
+ def post_order(node, children_by_parent_id, &block)
76
+ stack = [[node, false]]
77
+
78
+ until stack.empty?
79
+ current, visited = stack.pop
80
+ next block.call(current) if visited
81
+
82
+ enqueue_for_post_order(stack, current, children_by_parent_id)
83
+ end
84
+ end
85
+
86
+ def enqueue_for_post_order(stack, node, children_by_parent_id)
87
+ stack << [node, true]
88
+ (children_by_parent_id[node.id] || []).reverse_each { |child| stack << [child, false] }
89
+ end
90
+
91
+ def compute(node, children, results)
92
+ binary = node.demand_type == "binary"
93
+
94
+ {
95
+ own_demand: binary ? nil : node.demand_value,
96
+ aggregated_demand: binary ? nil : node.demand_value + numeric_children_sum(children, results),
97
+ binary: binary,
98
+ unresolved_binary_count: unresolved_binary_count(binary, children, results)
99
+ }
100
+ end
101
+
102
+ # Binary children are a magnitude firewall: their own aggregated_demand
103
+ # is nil and their numeric descendants never tunnel through them.
104
+ def numeric_children_sum(children, results)
105
+ children.reject { |child| results[child.id][:binary] }
106
+ .sum(BigDecimal("0")) { |child| results[child.id][:aggregated_demand] }
107
+ end
108
+
109
+ def unresolved_binary_count(binary, children, results)
110
+ (binary ? 1 : 0) + children.sum { |child| results[child.id][:unresolved_binary_count] }
111
+ end
112
+
113
+ # ------------------------------------------------------------------
114
+ # resolve_tree bookkeeping
115
+ # ------------------------------------------------------------------
116
+
117
+ def build_resolution_state(root_node, strictness)
118
+ nodes = root_node.subtree.to_a
119
+
120
+ {
121
+ strictness: strictness,
122
+ results: {},
123
+ children_by_parent_id: nodes.group_by { |node| parent_id_of(node) },
124
+ parent_id_by_node_id: nodes.to_h { |node| [node.id, parent_id_of(node)] },
125
+ custodies_by_ward_id: custodies_by_ward_id(nodes),
126
+ aggregation: aggregate_demand(root_node),
127
+ phases: phases_snapshot
128
+ }.merge(empty_accumulators)
129
+ end
130
+
131
+ def empty_accumulators
132
+ {
133
+ inherited_shortfall_by_node_id: Hash.new(BigDecimal("0")),
134
+ pending_binaries_by_node_id: Hash.new { |hash, key| hash[key] = [] }
135
+ }
136
+ end
137
+
138
+ def custodies_by_ward_id(nodes)
139
+ Custody.where(ward_id: nodes.map(&:id)).order(:priority_weight, :id).group_by(&:ward_id)
140
+ end
141
+
142
+ def resolve_and_escalate(node, state)
143
+ pending_binaries = state[:pending_binaries_by_node_id][node.id]
144
+
145
+ result = resolve_node(node, state, pending_binaries)
146
+ state[:results][node.id] = result
147
+
148
+ escalate(node, result, pending_binaries, state)
149
+ end
150
+
151
+ def escalate(node, result, pending_binaries, state)
152
+ parent_id = state[:parent_id_by_node_id][node.id]
153
+ return unless parent_id
154
+
155
+ escalate_numeric_shortfall(result, parent_id, state)
156
+ escalate_binary_pending(node, result, pending_binaries, parent_id, state)
157
+ end
158
+
159
+ def escalate_numeric_shortfall(result, parent_id, state)
160
+ return if result[:binary] || !result[:gap]&.positive?
161
+
162
+ state[:inherited_shortfall_by_node_id][parent_id] += result[:gap]
163
+ end
164
+
165
+ def escalate_binary_pending(node, result, pending_binaries, parent_id, state)
166
+ pending = state[:pending_binaries_by_node_id][parent_id]
167
+ pending << node if result[:binary] && !result[:binary_resolved]
168
+ pending.concat(pending_binaries.reject { |p| state[:results][p.id][:binary_resolved] })
169
+ end
170
+
171
+ # ------------------------------------------------------------------
172
+ # Per-node resolution
173
+ # ------------------------------------------------------------------
174
+
175
+ def resolve_node(node, state, pending_binaries)
176
+ binary = node.demand_type == "binary"
177
+ own_demand, inherited_shortfall, total_demand = demand_figures(node, binary, state)
178
+ attempts = []
179
+ figures = { binary: binary, total_demand: total_demand }
180
+
181
+ binary_resolved, remaining, pending_ids =
182
+ attempt_resolution(node, figures, pending_binaries, state, attempts)
183
+
184
+ numeric_view(binary, total_demand, remaining).merge(
185
+ own_demand: own_demand, inherited_shortfall: inherited_shortfall, binary: binary,
186
+ binary_resolved: binary_resolved, pending_binaries_escalated: pending_ids, attempts: attempts
187
+ )
188
+ end
189
+
190
+ def attempt_resolution(node, figures, pending_binaries, state, attempts)
191
+ eligible = eligible_for(node, state)
192
+ context = resolution_context(node, state)
193
+ phases = state[:phases]
194
+ binary_resolved = figures[:binary] ? resolve_own_binary(node, eligible, attempts, context, phases) : nil
195
+ runtime = { context: context, phases: phases }
196
+ remaining = resolve_numeric_pool(node, eligible, figures[:total_demand], attempts, runtime)
197
+ pending_ids = resolve_pending_binaries(eligible, pending_binaries, state[:results])
198
+ [binary_resolved, remaining, pending_ids]
199
+ end
200
+
201
+ def demand_figures(node, binary, state)
202
+ own_demand = binary ? nil : node.demand_value
203
+ inherited_shortfall = state[:inherited_shortfall_by_node_id][node.id]
204
+ [own_demand, inherited_shortfall, (own_demand || BigDecimal("0")) + inherited_shortfall]
205
+ end
206
+
207
+ def eligible_for(node, state)
208
+ custodies = state[:custodies_by_ward_id][node.id] || []
209
+ custodies.select { |custody| valid_under_strictness?(custody, state[:strictness]) }
210
+ end
211
+
212
+ def resolution_context(node, state)
213
+ siblings = (state[:children_by_parent_id][state[:parent_id_by_node_id][node.id]] || []) - [node]
214
+ { siblings: siblings, aggregation: state[:aggregation], strictness: state[:strictness] }
215
+ end
216
+
217
+ def numeric_view(binary, total_demand, remaining)
218
+ return { demanded: nil, resolved_amount: nil, gap: nil } if binary
219
+
220
+ { demanded: total_demand, resolved_amount: total_demand - remaining, gap: remaining }
221
+ end
222
+
223
+ # A binary node has no magnitude: only :resolved settles it. :failed
224
+ # and any Numeric outcome are both treated as "not yet", try the next
225
+ # custody. Phases run afterwards, same rule, if still unresolved.
226
+ def resolve_own_binary(node, eligible, attempts, context, phases)
227
+ eligible.select { |custody| custody.applies_to?(node) }.each do |custody|
228
+ outcome = ActionRegistry.call(custody.action_name, node, custody, nil)
229
+ attempts << { custody_id: custody.id, action_name: custody.action_name, outcome: outcome, via: :direct }
230
+ return true if outcome == :resolved
231
+ end
232
+
233
+ run_phases(node, nil, context, attempts, phases) { |outcome| outcome == :resolved }
234
+ end
235
+
236
+ def resolve_numeric_pool(node, eligible, total_demand, attempts, runtime)
237
+ validate_total_demand!(total_demand)
238
+ remaining = total_demand
239
+ return remaining unless remaining.positive?
240
+
241
+ remaining = try_numeric_custodies(node, eligible, remaining, attempts)
242
+ return remaining if remaining.zero?
243
+
244
+ run_phases(node, remaining, runtime[:context], attempts, runtime[:phases]) do |outcome|
245
+ remaining = apply_outcome(remaining, outcome)
246
+ remaining.zero?
247
+ end
248
+ remaining
249
+ end
250
+
251
+ def validate_total_demand!(total_demand)
252
+ return unless total_demand.negative?
253
+
254
+ raise ArgumentError, "total demand must be non-negative, got #{total_demand.inspect}"
255
+ end
256
+
257
+ def try_numeric_custodies(node, eligible, remaining, attempts)
258
+ eligible.select { |custody| custody.applies_to?(node) }.each do |custody|
259
+ outcome = ActionRegistry.call(custody.action_name, node, custody, remaining)
260
+ attempts << { custody_id: custody.id, action_name: custody.action_name, outcome: outcome, via: :direct }
261
+ remaining = apply_outcome(remaining, outcome)
262
+ break if remaining.zero?
263
+ end
264
+ remaining
265
+ end
266
+
267
+ def apply_outcome(remaining, outcome)
268
+ case outcome
269
+ when :resolved then BigDecimal("0")
270
+ when :failed then remaining
271
+ else remaining - outcome
272
+ end
273
+ end
274
+
275
+ # Runs registered phases, in registration order, between direct
276
+ # custodies and escalation. Each phase's outcome is validated with
277
+ # the exact same rule ActionRegistry.call uses. Stops at the first
278
+ # phase whose result satisfies the block's "done?" check.
279
+ def run_phases(node, remaining, context, attempts, phases)
280
+ phases.each do |phase|
281
+ outcome = phase[:handler].call(node, remaining, context)
282
+ ActionRegistry.validate_outcome!(phase[:name], outcome, remaining: remaining)
283
+ attempts << { custody_id: nil, action_name: phase[:name], outcome: outcome, via: :phase }
284
+
285
+ done = yield(outcome)
286
+ return done if done
287
+ end
288
+ false
289
+ end
290
+
291
+ # Pending binaries inherited from children: this node's OWN custodies
292
+ # are invoked once per pending item, but with `node` being the
293
+ # ORIGINAL escalated binary node (not this node) - the action needs
294
+ # to know what it's actually resolving, even though the custody
295
+ # consulted belongs to the node currently being processed (its
296
+ # custodian is acting on the pending node's behalf). The attempt is
297
+ # recorded on the PENDING NODE's own attempts, not this node's.
298
+ def resolve_pending_binaries(eligible, pending_binaries, results)
299
+ pending_binaries.map do |pending_node|
300
+ results[pending_node.id][:binary_resolved] = resolve_pending_binary?(eligible, pending_node, results)
301
+ pending_node.id
302
+ end
303
+ end
304
+
305
+ def resolve_pending_binary?(eligible, pending_node, results)
306
+ eligible.select { |custody| custody.applies_to?(pending_node) }.each do |custody|
307
+ outcome = ActionRegistry.call(custody.action_name, pending_node, custody, nil)
308
+ results[pending_node.id][:attempts] << {
309
+ custody_id: custody.id, action_name: custody.action_name, outcome: outcome, via: :escalation
310
+ }
311
+ return true if outcome == :resolved
312
+ end
313
+ false
314
+ end
315
+
316
+ # ------------------------------------------------------------------
317
+ # Numeric escalation settlement (see resolve_tree)
318
+ # ------------------------------------------------------------------
319
+
320
+ # A numeric node that couldn't fully resolve its total_demand (own +
321
+ # already-inherited) has its shortfall folded into the parent's total
322
+ # DURING the main post-order pass (see resolve_tree). Whether that
323
+ # shortfall was EVER actually covered can only be known once we've
324
+ # walked all the way up: a node's escalation is "resolved" if any
325
+ # ancestor's own final gap is zero (that ancestor's custodies covered
326
+ # the whole combined pool, including this node's contribution).
327
+ def settle_numeric_escalations!(results, parent_id_by_node_id, root_id)
328
+ results.each do |node_id, result|
329
+ next if node_id == root_id || result[:binary] || !result[:gap]&.positive?
330
+
331
+ settle_one_escalation!(node_id, result, parent_id_by_node_id, results)
332
+ end
333
+ end
334
+
335
+ def settle_one_escalation!(node_id, result, parent_id_by_node_id, results)
336
+ if ancestor_resolved?(node_id, parent_id_by_node_id, results)
337
+ result[:gap] = BigDecimal("0")
338
+ result[:attempts] << { custody_id: nil, action_name: nil, outcome: :resolved, via: :escalation }
339
+ else
340
+ result[:attempts] << { custody_id: nil, action_name: nil, outcome: :failed, via: :escalation }
341
+ end
342
+ end
343
+
344
+ def ancestor_resolved?(node_id, parent_id_by_node_id, results)
345
+ ancestor_id = parent_id_by_node_id[node_id]
346
+ while ancestor_id
347
+ ancestor_result = results[ancestor_id]
348
+ return true if !ancestor_result[:binary] && ancestor_result[:gap]&.zero?
349
+
350
+ ancestor_id = parent_id_by_node_id[ancestor_id]
351
+ end
352
+ false
353
+ end
354
+
355
+ def valid_under_strictness?(custody, strictness)
356
+ case strictness
357
+ when :valid then custody.currently_valid?
358
+ when :trustworthy then custody.trustworthy?
359
+ end
360
+ end
361
+
362
+ def validate_strictness!(strictness)
363
+ return if VALID_STRICTNESS_VALUES.include?(strictness)
364
+
365
+ raise ArgumentError,
366
+ "unsupported strictness #{strictness.inspect}; expected one of #{VALID_STRICTNESS_VALUES.inspect}"
367
+ end
368
+
369
+ def phases_snapshot
370
+ @phases_mutex.synchronize { @phases.dup.freeze }
371
+ end
372
+ end
373
+ end
374
+ end
375
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext"
5
+ require "action_dispatch"
6
+ require "rails/engine"
7
+
8
+ module Custodian
9
+ module Core
10
+ class Engine < ::Rails::Engine
11
+ isolate_namespace Custodian::Core
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Custodian
4
+ module Core
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "core/version"
4
+ require_relative "core/engine"
5
+
6
+ module Custodian
7
+ module Core
8
+ class Error < StandardError; end
9
+ end
10
+ end
11
+
12
+ require_relative "core/action_registry"
13
+ require_relative "core/adjuster"
@@ -0,0 +1,6 @@
1
+ module Custodian
2
+ module Core
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,168 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: custodian-core
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Lucas nunes de sousa
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: actionpack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: activerecord
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '7.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '7.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: activesupport
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '7.0'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '9.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '7.0'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '9.0'
72
+ - !ruby/object:Gem::Dependency
73
+ name: ancestry
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '4.0'
79
+ - - "<"
80
+ - !ruby/object:Gem::Version
81
+ version: '6.0'
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '4.0'
89
+ - - "<"
90
+ - !ruby/object:Gem::Version
91
+ version: '6.0'
92
+ - !ruby/object:Gem::Dependency
93
+ name: railties
94
+ requirement: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '7.0'
99
+ - - "<"
100
+ - !ruby/object:Gem::Version
101
+ version: '9.0'
102
+ type: :runtime
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '7.0'
109
+ - - "<"
110
+ - !ruby/object:Gem::Version
111
+ version: '9.0'
112
+ description: 'Custodian::Core resolves a chain of custody over a tree of responsibilities:
113
+ it identifies the next responsible party and demands the action a previous ward
114
+ could not fulfill, climbing the tree until someone can act. It carries no business
115
+ rules from any specific domain - concrete domains are implemented as satellite gems
116
+ that register actions into this core via a registry.'
117
+ email:
118
+ - lucas.ns.software.engineer@gmail.com
119
+ executables: []
120
+ extensions: []
121
+ extra_rdoc_files: []
122
+ files:
123
+ - CHANGELOG.md
124
+ - LICENSE.txt
125
+ - README.md
126
+ - app/models/custodian/core/application_record.rb
127
+ - app/models/custodian/core/custody.rb
128
+ - app/models/custodian/core/custody_node_rule.rb
129
+ - app/models/custodian/core/custody_repudiated_node.rb
130
+ - app/models/custodian/core/graph.rb
131
+ - app/models/custodian/core/node.rb
132
+ - custodian-core.gemspec
133
+ - db/migrate/20260703015208_create_custodian_core_nodes.rb
134
+ - db/migrate/20260703103004_create_custodian_core_graphs.rb
135
+ - db/migrate/20260703215106_create_custodian_core_custodies.rb
136
+ - db/migrate/20260707000238_create_custodian_core_custody_node_rules.rb
137
+ - db/migrate/20260707000247_create_custodian_core_custody_repudiated_nodes.rb
138
+ - lib/custodian/core.rb
139
+ - lib/custodian/core/action_registry.rb
140
+ - lib/custodian/core/adjuster.rb
141
+ - lib/custodian/core/engine.rb
142
+ - lib/custodian/core/version.rb
143
+ - sig/custodian/core.rbs
144
+ homepage: https://github.com/lucasnssoftwareengineer-alt/custodian-core
145
+ licenses:
146
+ - MIT
147
+ metadata:
148
+ source_code_uri: https://github.com/lucasnssoftwareengineer-alt/custodian-core
149
+ changelog_uri: https://github.com/lucasnssoftwareengineer-alt/custodian-core/blob/main/CHANGELOG.md
150
+ rubygems_mfa_required: 'true'
151
+ rdoc_options: []
152
+ require_paths:
153
+ - lib
154
+ required_ruby_version: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: 3.2.0
159
+ required_rubygems_version: !ruby/object:Gem::Requirement
160
+ requirements:
161
+ - - ">="
162
+ - !ruby/object:Gem::Version
163
+ version: '0'
164
+ requirements: []
165
+ rubygems_version: 3.6.9
166
+ specification_version: 4
167
+ summary: A domain-agnostic chain-of-custody engine for Ruby.
168
+ test_files: []