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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +204 -0
- data/app/models/custodian/core/application_record.rb +9 -0
- data/app/models/custodian/core/custody.rb +74 -0
- data/app/models/custodian/core/custody_node_rule.rb +39 -0
- data/app/models/custodian/core/custody_repudiated_node.rb +14 -0
- data/app/models/custodian/core/graph.rb +22 -0
- data/app/models/custodian/core/node.rb +37 -0
- data/custodian-core.gemspec +50 -0
- data/db/migrate/20260703015208_create_custodian_core_nodes.rb +19 -0
- data/db/migrate/20260703103004_create_custodian_core_graphs.rb +17 -0
- data/db/migrate/20260703215106_create_custodian_core_custodies.rb +29 -0
- data/db/migrate/20260707000238_create_custodian_core_custody_node_rules.rb +20 -0
- data/db/migrate/20260707000247_create_custodian_core_custody_repudiated_nodes.rb +17 -0
- data/lib/custodian/core/action_registry.rb +100 -0
- data/lib/custodian/core/adjuster.rb +375 -0
- data/lib/custodian/core/engine.rb +14 -0
- data/lib/custodian/core/version.rb +7 -0
- data/lib/custodian/core.rb +13 -0
- data/sig/custodian/core.rbs +6 -0
- metadata +168 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f7964e4623e30dd16ef4b7c9dd708f49d143ecfd98146470b7a78eb582dcebea
|
|
4
|
+
data.tar.gz: e068635b45a959416e80289cc36bb6fe2e3cb3a93fa89b81ce48a81c63c38b7a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a49ad921937a9c02817bea8da23c8761daa3c3a5af18dddb017d8853cf7fe01b7bf03b6a469e6a1c263f73043fd47af55dcda1dd509776f6a8d464bb460e4179
|
|
7
|
+
data.tar.gz: 96b332f0053a92e41c503ef9e2417228ead9dd52aeb8f1521a51c8e4c7c5c0c69b239b9eccd7002d154cf671807b14f3d53c5140778cde9e7902ce302bbad8bc
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
- Require Ruby 3.2+, support Rails 7.x/8.x and ancestry 4.x/5.x explicitly.
|
|
4
|
+
- Reject negative demands and non-finite, negative, or over-remaining numeric
|
|
5
|
+
action outcomes.
|
|
6
|
+
- Validate resolution strictness, snapshot phases safely across threads, and
|
|
7
|
+
traverse deep trees iteratively.
|
|
8
|
+
- Limit the built gem to consumer-facing code, migrations, signatures, and
|
|
9
|
+
top-level release documentation.
|
|
10
|
+
|
|
11
|
+
## [0.1.0] - 2026-07-07
|
|
12
|
+
|
|
13
|
+
Initial release: a domain-agnostic chain-of-custody engine over a tree of
|
|
14
|
+
responsibilities, ready for satellite gems to build concrete domains on top
|
|
15
|
+
of. 107 examples, 0 RuboCop offenses.
|
|
16
|
+
|
|
17
|
+
### Models
|
|
18
|
+
|
|
19
|
+
- `Node` — the ward: a tree (via `ancestry`) of binary or numeric demands,
|
|
20
|
+
with a polymorphic `subject` (which may be another `Node`).
|
|
21
|
+
- `Graph` — a container anchored at a single root `Node`, with a
|
|
22
|
+
polymorphic, optional `owner`.
|
|
23
|
+
- `Custody` — the agreement between a custodian and a ward: action name,
|
|
24
|
+
priority, JSON action params, and a validity/status model
|
|
25
|
+
(`eternal`/`fixed_term`/`punctual` × `active`/`at_risk`/`broken`/`expired`)
|
|
26
|
+
exposed via `currently_valid?`, `trustworthy?`, and `healthcheck`.
|
|
27
|
+
- `CustodyNodeRule` / `CustodyRepudiatedNode` — per-descendant exceptions
|
|
28
|
+
(`exclude`/`full`/`limit_pct`/`limit_amount` rules, and outright
|
|
29
|
+
repudiations) that qualify how a `Custody` applies across its ward's
|
|
30
|
+
subtree, surfaced through `Custody#applies_to?`.
|
|
31
|
+
|
|
32
|
+
### ActionRegistry
|
|
33
|
+
|
|
34
|
+
- The gem's one extension point: `register`/`unregister`/`call`/
|
|
35
|
+
`registered?`/`clear!`, Symbol-normalized, Mutex-guarded.
|
|
36
|
+
- Strict outcome contract: `:resolved`, `:failed`, or a non-negative
|
|
37
|
+
`Numeric`, in the same unit as the node's `demand_value`. Anything else —
|
|
38
|
+
including negative Numerics — raises immediately rather than propagating
|
|
39
|
+
a silent bug downstream.
|
|
40
|
+
|
|
41
|
+
### Adjuster
|
|
42
|
+
|
|
43
|
+
- `aggregate_demand` — pure, read-only consolidation of numeric demand
|
|
44
|
+
across a subtree in a single query, with binary nodes acting as a
|
|
45
|
+
magnitude firewall (they contribute no quantity to an ancestor's total,
|
|
46
|
+
but are still counted via `unresolved_binary_count`).
|
|
47
|
+
- `resolve_tree` — walks a subtree post-order, consulting each node's
|
|
48
|
+
direct custodies (filtered by caller-chosen `strictness: :valid` or
|
|
49
|
+
`:trustworthy`, and by `Custody#applies_to?`), then registered phases,
|
|
50
|
+
then escalating whatever remains to the direct parent with per-level
|
|
51
|
+
encapsulation: a child's shortfall becomes part of its parent's own
|
|
52
|
+
demand, with no marker distinguishing the two at invocation time. A
|
|
53
|
+
grandparent never sees a grandchild; the full story is only
|
|
54
|
+
reconstructable from each node's `attempts` audit trail. Binary
|
|
55
|
+
escalation is supported too: a parent's custodians can be asked to act on
|
|
56
|
+
a still-pending binary descendant's behalf.
|
|
57
|
+
- `register_phase` / `clear_phases!` — the hook a future sibling-generosity
|
|
58
|
+
satellite will plug into. Not built yet; only the extension point is.
|
|
59
|
+
- Pure resolution throughout: neither method persists or mutates any
|
|
60
|
+
record.
|
|
61
|
+
|
|
62
|
+
### Documentation
|
|
63
|
+
|
|
64
|
+
- README with the project's guiding definition, vocabulary, quick start,
|
|
65
|
+
outcome contract, resolution order, and explicitly non-domain examples.
|
|
66
|
+
- Eight ADRs recording the project's design decisions, from the
|
|
67
|
+
domain-agnostic-core constraint through registry defensiveness.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lucasnshuntervoa
|
|
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,204 @@
|
|
|
1
|
+
# custodian-core
|
|
2
|
+
|
|
3
|
+
The core has no business rules from any specific domain — it only knows how
|
|
4
|
+
to resolve a chain of custody: identify the next responsible party, and
|
|
5
|
+
demand from them the execution of the action the previous ward could not
|
|
6
|
+
fulfill, climbing the tree until someone has the capacity to act.
|
|
7
|
+
|
|
8
|
+
`custodian-core` is a Ruby (Rails engine) gem that implements this
|
|
9
|
+
resolution mechanism over a tree of `Node` records. It ships no domain
|
|
10
|
+
vocabulary — no money, no storage, no networking — only the shape of
|
|
11
|
+
responsibility and the machinery to walk it. Concrete domains plug in as
|
|
12
|
+
satellite gems.
|
|
13
|
+
|
|
14
|
+
> ⚠️ **Early-stage (0.1.0).** The API may still change and this gem has not
|
|
15
|
+
> yet been battle-tested in production. It is provided "as is", without
|
|
16
|
+
> warranty of any kind (see [License](#license)). Use at your own risk.
|
|
17
|
+
|
|
18
|
+
## Vocabulary
|
|
19
|
+
|
|
20
|
+
- **Node** — something that needs to be resolved: the **ward**. Nodes form
|
|
21
|
+
a tree (via [ancestry](https://github.com/stefankroes/ancestry)); a node's
|
|
22
|
+
demand is either binary ("this must be done") or numeric (a quantity to
|
|
23
|
+
cover).
|
|
24
|
+
- **Custody** — the agreement between a **custodian** (whoever takes on
|
|
25
|
+
responsibility for a ward) and the ward itself: which action to call, at
|
|
26
|
+
what priority, and under what validity window.
|
|
27
|
+
- **custodian** — the party who took on a `Custody`. It can be anything
|
|
28
|
+
polymorphic — including another `Node`.
|
|
29
|
+
- **Adjuster** — walks the tree and resolves it, invoking custodies through
|
|
30
|
+
the `ActionRegistry` and escalating what nobody could cover. Named after
|
|
31
|
+
the insurance claims adjuster: someone who reviews a policy, evaluates
|
|
32
|
+
what happened, and decides who's responsible for covering it.
|
|
33
|
+
- **ActionRegistry** — the plug-in point. Host apps and satellite gems
|
|
34
|
+
register named actions here; the core never knows what an action
|
|
35
|
+
actually does.
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
root = Custodian::Core::Node.create!(demand_type: "fixed", demand_value: 100)
|
|
41
|
+
child = Custodian::Core::Node.create!(demand_type: "fixed", demand_value: 50, parent: root)
|
|
42
|
+
|
|
43
|
+
Custodian::Core::Custody.create!(ward: root, action_name: "cover_shortfall")
|
|
44
|
+
|
|
45
|
+
Custodian::Core::ActionRegistry.register(:cover_shortfall) do |node, custody, remaining|
|
|
46
|
+
remaining # fully resolves whatever it's asked to cover
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
result = Custodian::Core::Adjuster.resolve_tree(root)
|
|
50
|
+
|
|
51
|
+
result[child.id][:gap] #=> 0.0 (BigDecimal) - resolved via escalation into root
|
|
52
|
+
result[root.id][:gap] #=> 0.0 - root's own 100 + child's inherited 50 = 150, fully covered
|
|
53
|
+
result[root.id][:attempts]
|
|
54
|
+
#=> [{ custody_id: 1, action_name: "cover_shortfall", outcome: 0.15e3, via: :direct }]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`resolve_tree` returns a Hash keyed by node id. Each entry reports what was
|
|
58
|
+
`demanded`, how much of it is `own_demand` vs `inherited_shortfall`, the
|
|
59
|
+
`resolved_amount` and remaining `gap`, binary-specific fields, and a full
|
|
60
|
+
`attempts` audit trail (`via: :direct | :phase | :escalation`) for
|
|
61
|
+
reconstructing the whole story after the fact.
|
|
62
|
+
|
|
63
|
+
## The outcome contract
|
|
64
|
+
|
|
65
|
+
A registered action receives `(node, custody, remaining)` and must return:
|
|
66
|
+
|
|
67
|
+
- `:resolved` — fully satisfied, stop trying custodies for this node
|
|
68
|
+
- `:failed` — this custodian couldn't act, try the next one
|
|
69
|
+
- a non-negative `Numeric` — partial resolution, **in the same unit as the
|
|
70
|
+
node's `demand_value`** (see
|
|
71
|
+
[ADR 0004](docs/adr/0004-numeric-outcome-unit-commensurability.md); the
|
|
72
|
+
core cannot enforce unit correctness, only that the number itself is
|
|
73
|
+
well-formed)
|
|
74
|
+
|
|
75
|
+
Anything else — `true`, `nil`, a `String`, a negative number — raises
|
|
76
|
+
`ActionRegistry::InvalidOutcomeError` immediately. See
|
|
77
|
+
[ADR 0008](docs/adr/0008-registry-defensiveness.md) for why this is
|
|
78
|
+
deliberately strict rather than forgiving.
|
|
79
|
+
|
|
80
|
+
## Security: action registration is a trust boundary
|
|
81
|
+
|
|
82
|
+
Registered actions and phase handlers are ordinary Ruby blocks that
|
|
83
|
+
`custodian-core` executes with your application's full privileges. The core
|
|
84
|
+
validates only the *shape* of what an action returns — it does **not**
|
|
85
|
+
sandbox what an action does. Treat registration as privileged
|
|
86
|
+
configuration:
|
|
87
|
+
|
|
88
|
+
- Register actions only from code you control (your app, or satellite gems
|
|
89
|
+
you trust). Never build or register an action from untrusted input.
|
|
90
|
+
- Never derive an `action_name` to invoke, or an action's body, from
|
|
91
|
+
user-supplied data without validating it against a fixed allowlist of
|
|
92
|
+
known actions.
|
|
93
|
+
- `action_params` on a `Custody` is passed to the action as-is; validate
|
|
94
|
+
and authorize any host-exposed path that lets an end user set
|
|
95
|
+
`action_name`, `action_params`, or custody attributes, since those
|
|
96
|
+
determine what code runs and on whose behalf.
|
|
97
|
+
|
|
98
|
+
## Resolution order and encapsulation
|
|
99
|
+
|
|
100
|
+
For each node, post-order (children before parents), the Adjuster tries, in
|
|
101
|
+
order: **direct custodies** (by `priority_weight`, then id), then
|
|
102
|
+
**registered phases** (an extension point, e.g. for a future
|
|
103
|
+
sibling-generosity satellite), then **escalation**.
|
|
104
|
+
|
|
105
|
+
Escalation is the gem's most distinctive idea: if a node still can't cover
|
|
106
|
+
its demand after the two steps above, that shortfall is folded into its
|
|
107
|
+
direct parent's own demand before the parent is even processed — with no
|
|
108
|
+
marker distinguishing "my own demand" from "what my child couldn't cover."
|
|
109
|
+
The parent's custodians never know a child failed; they just see a bigger
|
|
110
|
+
number. This means **a grandparent never sees a grandchild** — each level
|
|
111
|
+
only ever perceives one level down, exactly like a real management chain.
|
|
112
|
+
The full story (who ultimately paid for whom) is only reconstructable from
|
|
113
|
+
the `attempts` audit trail, not from any single node's own view. See
|
|
114
|
+
[ADR 0006](docs/adr/0006-escalation-with-per-level-encapsulation.md).
|
|
115
|
+
|
|
116
|
+
Binary nodes are a **magnitude firewall**: a numeric node's demand never
|
|
117
|
+
aggregates through a binary ancestor, even though escalation still walks
|
|
118
|
+
*into* one if it's the direct parent — aggregation and escalation are
|
|
119
|
+
different mechanisms. See
|
|
120
|
+
[ADR 0005](docs/adr/0005-binary-nodes-as-magnitude-firewalls.md).
|
|
121
|
+
|
|
122
|
+
## Strictness: `:valid` vs `:trustworthy`
|
|
123
|
+
|
|
124
|
+
`resolve_tree(root, strictness: :valid)` (the default) consults any custody
|
|
125
|
+
that is currently legally valid, regardless of risk signals.
|
|
126
|
+
`strictness: :trustworthy` additionally excludes custodies flagged
|
|
127
|
+
`at_risk` — for callers who care not just "is this custody in force" but
|
|
128
|
+
"do I actually trust it right now." See
|
|
129
|
+
[ADR 0007](docs/adr/0007-strictness-as-a-caller-choice.md).
|
|
130
|
+
|
|
131
|
+
## This is domain-agnostic — satellite gems bring the meaning
|
|
132
|
+
|
|
133
|
+
`custodian-core` never mentions money, storage, or networking. Examples of
|
|
134
|
+
what a satellite gem could layer on top, none of which live here:
|
|
135
|
+
|
|
136
|
+
- **Finance**: a `custodian-payments` gem registering `:charge_card` or
|
|
137
|
+
`:draw_from_reserve` actions, where a Node's numeric demand is an amount
|
|
138
|
+
owed and escalation models who is ultimately liable for a shortfall.
|
|
139
|
+
- **Storage failover**: a `custodian-storage` gem registering
|
|
140
|
+
`:replicate_to_backup`, where a binary Node represents "this object must
|
|
141
|
+
be durably stored somewhere" and custody escalates through a chain of
|
|
142
|
+
storage backends.
|
|
143
|
+
- **Network routing**: a `custodian-routing` gem registering
|
|
144
|
+
`:forward_via_peer`, where demand is bandwidth to place and unresolved
|
|
145
|
+
capacity escalates to an upstream peer.
|
|
146
|
+
|
|
147
|
+
## Satellites (not yet built)
|
|
148
|
+
|
|
149
|
+
Planned extensions that do not exist in this gem yet:
|
|
150
|
+
|
|
151
|
+
- **Sibling generosity / "brotherhood"** — a phase (via `register_phase`)
|
|
152
|
+
that lets a resolved sibling's surplus cover another sibling's shortfall
|
|
153
|
+
before escalating to the parent.
|
|
154
|
+
- **Resource pools** — shared capacity a custodian can draw from across
|
|
155
|
+
multiple wards, instead of resolving each in isolation.
|
|
156
|
+
- **Execution history / honor tracking** — a record of how reliably a
|
|
157
|
+
custodian has actually resolved what it took on, feeding back into future
|
|
158
|
+
eligibility decisions.
|
|
159
|
+
|
|
160
|
+
## Installation
|
|
161
|
+
|
|
162
|
+
### Compatibility
|
|
163
|
+
|
|
164
|
+
`custodian-core` 0.1 supports Ruby 3.2 or newer, Rails 7.x and 8.x, and
|
|
165
|
+
`ancestry` 4.x and 5.x. The lower Ruby bound keeps the supported runtime
|
|
166
|
+
aligned with the CI matrix and with the currently supported Rails majors.
|
|
167
|
+
|
|
168
|
+
This gem is not published to RubyGems yet. While it lives on GitHub only,
|
|
169
|
+
add it to your host Rails application's Gemfile via git:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
gem "custodian-core", git: "https://github.com/lucasnssoftwareengineer-alt/custodian-core"
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Once it is published to RubyGems, this becomes simply:
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
gem "custodian-core"
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Then install the engine's migrations:
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
bundle exec rails custodian_core:install:migrations
|
|
185
|
+
bundle exec rails db:migrate
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Running tests
|
|
189
|
+
|
|
190
|
+
```sh
|
|
191
|
+
bundle exec rake # runs both rspec and rubocop
|
|
192
|
+
bundle exec rspec # specs only
|
|
193
|
+
bundle exec rubocop # lint only
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Architecture decisions
|
|
197
|
+
|
|
198
|
+
See [docs/adr](docs/adr/0000-index.md) for the full record of design
|
|
199
|
+
decisions and their rationale.
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
The gem is available as open source under the terms of the
|
|
204
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Custodian
|
|
4
|
+
module Core
|
|
5
|
+
class Custody < ApplicationRecord
|
|
6
|
+
self.table_name = "custodian_core_custodies"
|
|
7
|
+
|
|
8
|
+
VALIDITY_TYPES = %w[eternal fixed_term punctual].freeze
|
|
9
|
+
STATUSES = %w[active at_risk broken expired].freeze
|
|
10
|
+
|
|
11
|
+
belongs_to :custodian, polymorphic: true, optional: true
|
|
12
|
+
belongs_to :ward, class_name: "Custodian::Core::Node"
|
|
13
|
+
|
|
14
|
+
has_many :node_rules, class_name: "Custodian::Core::CustodyNodeRule", dependent: :destroy
|
|
15
|
+
has_many :repudiated_nodes, class_name: "Custodian::Core::CustodyRepudiatedNode", dependent: :destroy
|
|
16
|
+
|
|
17
|
+
validates :action_name, presence: true
|
|
18
|
+
validates :priority_weight, numericality: { only_integer: true }
|
|
19
|
+
validates :validity_type, inclusion: { in: VALIDITY_TYPES }
|
|
20
|
+
validates :status, inclusion: { in: STATUSES }
|
|
21
|
+
|
|
22
|
+
# Answers only the exclusion question: is this node repudiated, or does
|
|
23
|
+
# it have an "exclude" rule? It does NOT interpret limit_pct/limit_amount
|
|
24
|
+
# rule values (i.e. it doesn't decide how much of the demand applies) -
|
|
25
|
+
# that quantitative interpretation is the Adjuster's job, in a later step.
|
|
26
|
+
def applies_to?(node)
|
|
27
|
+
return false if repudiated_nodes.exists?(node: node)
|
|
28
|
+
return false if node_rules.exists?(node: node, rule_type: "exclude")
|
|
29
|
+
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def at_risk?
|
|
34
|
+
status == "at_risk"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def trustworthy?(at_time = Time.current)
|
|
38
|
+
currently_valid?(at_time) && !at_risk?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def healthcheck(at_time = Time.current)
|
|
42
|
+
return :broken if status == "broken"
|
|
43
|
+
return :expired if status == "expired"
|
|
44
|
+
return :expired unless currently_valid?(at_time)
|
|
45
|
+
return :at_risk if at_risk?
|
|
46
|
+
|
|
47
|
+
:active
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def currently_valid?(at_time = Time.current)
|
|
51
|
+
return false if %w[broken expired].include?(status)
|
|
52
|
+
|
|
53
|
+
case validity_type
|
|
54
|
+
when "eternal"
|
|
55
|
+
true
|
|
56
|
+
when "fixed_term"
|
|
57
|
+
fixed_term_valid_at?(at_time)
|
|
58
|
+
when "punctual"
|
|
59
|
+
punctual_valid_at?(at_time)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def fixed_term_valid_at?(at_time)
|
|
66
|
+
(valid_from.nil? || at_time >= valid_from) && (valid_until.nil? || at_time <= valid_until)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def punctual_valid_at?(at_time)
|
|
70
|
+
valid_until.nil? || at_time <= valid_until
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Custodian
|
|
4
|
+
module Core
|
|
5
|
+
class CustodyNodeRule < ApplicationRecord
|
|
6
|
+
self.table_name = "custodian_core_custody_node_rules"
|
|
7
|
+
|
|
8
|
+
RULE_TYPES = %w[exclude full limit_pct limit_amount].freeze
|
|
9
|
+
|
|
10
|
+
belongs_to :custody, class_name: "Custodian::Core::Custody"
|
|
11
|
+
belongs_to :node, class_name: "Custodian::Core::Node"
|
|
12
|
+
|
|
13
|
+
validates :rule_type, inclusion: { in: RULE_TYPES }
|
|
14
|
+
validates :custody_id, uniqueness: { scope: :node_id }
|
|
15
|
+
validate :rule_value_matches_rule_type
|
|
16
|
+
validate :rule_value_within_percentage_bounds
|
|
17
|
+
|
|
18
|
+
scope :for_node, ->(node) { where(node: node) }
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def rule_value_matches_rule_type
|
|
23
|
+
if %w[exclude full].include?(rule_type)
|
|
24
|
+
errors.add(:rule_value, "must be blank when rule_type is #{rule_type}") if rule_value.present?
|
|
25
|
+
elsif rule_value.nil?
|
|
26
|
+
errors.add(:rule_value, "can't be blank when rule_type is #{rule_type}")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def rule_value_within_percentage_bounds
|
|
31
|
+
return unless rule_type == "limit_pct" && rule_value.present?
|
|
32
|
+
|
|
33
|
+
return if rule_value.positive? && rule_value <= 100
|
|
34
|
+
|
|
35
|
+
errors.add(:rule_value, "must be greater than 0 and less than or equal to 100 for limit_pct")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Custodian
|
|
4
|
+
module Core
|
|
5
|
+
class CustodyRepudiatedNode < ApplicationRecord
|
|
6
|
+
self.table_name = "custodian_core_custody_repudiated_nodes"
|
|
7
|
+
|
|
8
|
+
belongs_to :custody, class_name: "Custodian::Core::Custody"
|
|
9
|
+
belongs_to :node, class_name: "Custodian::Core::Node"
|
|
10
|
+
|
|
11
|
+
validates :custody_id, uniqueness: { scope: :node_id }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Custodian
|
|
4
|
+
module Core
|
|
5
|
+
class Graph < ApplicationRecord
|
|
6
|
+
self.table_name = "custodian_core_graphs"
|
|
7
|
+
|
|
8
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
9
|
+
belongs_to :root_node, class_name: "Custodian::Core::Node"
|
|
10
|
+
|
|
11
|
+
validate :root_node_must_be_a_root
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def root_node_must_be_a_root
|
|
16
|
+
return if root_node.nil? || root_node.root?
|
|
17
|
+
|
|
18
|
+
errors.add(:root_node, "must be a root node (cannot have a parent)")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ancestry"
|
|
4
|
+
|
|
5
|
+
module Custodian
|
|
6
|
+
module Core
|
|
7
|
+
class Node < ApplicationRecord
|
|
8
|
+
self.table_name = "custodian_core_nodes"
|
|
9
|
+
|
|
10
|
+
has_ancestry
|
|
11
|
+
|
|
12
|
+
belongs_to :subject, polymorphic: true, optional: true
|
|
13
|
+
|
|
14
|
+
DEMAND_TYPES = %w[binary fixed variable_manual variable_by_tag].freeze
|
|
15
|
+
|
|
16
|
+
validates :demand_type, presence: true, inclusion: { in: DEMAND_TYPES }
|
|
17
|
+
validates :demand_value, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
|
|
18
|
+
validate :demand_value_matches_demand_type
|
|
19
|
+
|
|
20
|
+
# Node deliberately has no graph_id column: graph membership is resolved
|
|
21
|
+
# via the ancestry root instead, to avoid an unused foreign key.
|
|
22
|
+
def graph
|
|
23
|
+
Graph.find_by(root_node: root)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def demand_value_matches_demand_type
|
|
29
|
+
if demand_type == "binary"
|
|
30
|
+
errors.add(:demand_value, "must be blank when demand_type is binary") if demand_value.present?
|
|
31
|
+
elsif demand_value.nil?
|
|
32
|
+
errors.add(:demand_value, "can't be blank unless demand_type is binary")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/custodian/core/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "custodian-core"
|
|
7
|
+
spec.version = Custodian::Core::VERSION
|
|
8
|
+
spec.authors = ["Lucas nunes de sousa"]
|
|
9
|
+
spec.email = ["lucas.ns.software.engineer@gmail.com"]
|
|
10
|
+
spec.homepage = "https://github.com/lucasnssoftwareengineer-alt/custodian-core"
|
|
11
|
+
spec.metadata["source_code_uri"] = "https://github.com/lucasnssoftwareengineer-alt/custodian-core"
|
|
12
|
+
spec.metadata["changelog_uri"] = "https://github.com/lucasnssoftwareengineer-alt/custodian-core/blob/main/CHANGELOG.md"
|
|
13
|
+
spec.summary = "A domain-agnostic chain-of-custody engine for Ruby."
|
|
14
|
+
spec.description = "Custodian::Core resolves a chain of custody over a tree of responsibilities: " \
|
|
15
|
+
"it identifies the next responsible party and demands the action a previous " \
|
|
16
|
+
"ward could not fulfill, climbing the tree until someone can act. It carries " \
|
|
17
|
+
"no business rules from any specific domain - concrete domains are implemented " \
|
|
18
|
+
"as satellite gems that register actions into this core via a registry."
|
|
19
|
+
spec.license = "MIT"
|
|
20
|
+
spec.required_ruby_version = ">= 3.2.0"
|
|
21
|
+
|
|
22
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
23
|
+
|
|
24
|
+
# Keep the package independent from Git and deliberately limited to files
|
|
25
|
+
# useful to consumers. Development configuration, specs, CI, and internal
|
|
26
|
+
# architecture records remain in the repository but not in the gem.
|
|
27
|
+
spec.files = Dir.chdir(__dir__) do
|
|
28
|
+
%w[CHANGELOG.md LICENSE.txt README.md custodian-core.gemspec] +
|
|
29
|
+
Dir.glob(%w[app/**/*.rb db/**/*.rb lib/**/*.rb sig/**/*.rbs])
|
|
30
|
+
end
|
|
31
|
+
spec.bindir = "exe"
|
|
32
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
33
|
+
spec.require_paths = ["lib"]
|
|
34
|
+
|
|
35
|
+
# This gem is a Rails engine so that satellite gems can rely on it to ship
|
|
36
|
+
# ActiveRecord models and migrations in later steps. We depend on the
|
|
37
|
+
# specific Rails components isolate_namespace/ActiveRecord actually need
|
|
38
|
+
# (actionpack for Rails::Engine's route set, activerecord for models)
|
|
39
|
+
# rather than the "rails" umbrella gem, to avoid pulling in ActionMailer,
|
|
40
|
+
# ActionCable, ActionText, ActiveStorage, ActionView, Nokogiri, etc. into a
|
|
41
|
+
# domain-agnostic core (see docs/adr/0001-domain-agnostic-core.md).
|
|
42
|
+
spec.add_dependency "actionpack", ">= 7.0", "< 9.0"
|
|
43
|
+
spec.add_dependency "activerecord", ">= 7.0", "< 9.0"
|
|
44
|
+
spec.add_dependency "activesupport", ">= 7.0", "< 9.0"
|
|
45
|
+
spec.add_dependency "ancestry", ">= 4.0", "< 6.0"
|
|
46
|
+
spec.add_dependency "railties", ">= 7.0", "< 9.0"
|
|
47
|
+
|
|
48
|
+
# For more information and examples about making a new gem, check out our
|
|
49
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
|
50
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateCustodianCoreNodes < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :custodian_core_nodes do |t|
|
|
6
|
+
t.string :ancestry, index: true
|
|
7
|
+
|
|
8
|
+
t.string :subject_type
|
|
9
|
+
t.bigint :subject_id
|
|
10
|
+
|
|
11
|
+
t.string :demand_type, null: false
|
|
12
|
+
t.decimal :demand_value, precision: 15, scale: 4
|
|
13
|
+
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
add_index :custodian_core_nodes, %i[subject_type subject_id]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateCustodianCoreGraphs < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :custodian_core_graphs do |t|
|
|
6
|
+
t.string :owner_type
|
|
7
|
+
t.bigint :owner_id
|
|
8
|
+
|
|
9
|
+
t.bigint :root_node_id, null: false
|
|
10
|
+
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :custodian_core_graphs, %i[owner_type owner_id]
|
|
15
|
+
add_foreign_key :custodian_core_graphs, :custodian_core_nodes, column: :root_node_id
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateCustodianCoreCustodies < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :custodian_core_custodies do |t|
|
|
6
|
+
t.string :custodian_type
|
|
7
|
+
t.bigint :custodian_id
|
|
8
|
+
|
|
9
|
+
t.bigint :ward_id, null: false
|
|
10
|
+
|
|
11
|
+
t.string :action_name, null: false
|
|
12
|
+
t.json :action_params, null: false, default: {}
|
|
13
|
+
|
|
14
|
+
t.integer :priority_weight, null: false, default: 0
|
|
15
|
+
|
|
16
|
+
t.string :validity_type, null: false, default: "eternal"
|
|
17
|
+
t.datetime :valid_from
|
|
18
|
+
t.datetime :valid_until
|
|
19
|
+
|
|
20
|
+
t.string :status, null: false, default: "active"
|
|
21
|
+
|
|
22
|
+
t.timestamps
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
add_index :custodian_core_custodies, %i[custodian_type custodian_id],
|
|
26
|
+
name: "index_custodian_core_custodies_on_custodian"
|
|
27
|
+
add_foreign_key :custodian_core_custodies, :custodian_core_nodes, column: :ward_id
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateCustodianCoreCustodyNodeRules < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :custodian_core_custody_node_rules do |t|
|
|
6
|
+
t.bigint :custody_id, null: false
|
|
7
|
+
t.bigint :node_id, null: false
|
|
8
|
+
|
|
9
|
+
t.string :rule_type, null: false
|
|
10
|
+
t.decimal :rule_value, precision: 15, scale: 4
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :custodian_core_custody_node_rules, %i[custody_id node_id],
|
|
16
|
+
unique: true, name: "index_custody_node_rules_on_custody_and_node"
|
|
17
|
+
add_foreign_key :custodian_core_custody_node_rules, :custodian_core_custodies, column: :custody_id
|
|
18
|
+
add_foreign_key :custodian_core_custody_node_rules, :custodian_core_nodes, column: :node_id
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateCustodianCoreCustodyRepudiatedNodes < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :custodian_core_custody_repudiated_nodes do |t|
|
|
6
|
+
t.bigint :custody_id, null: false
|
|
7
|
+
t.bigint :node_id, null: false
|
|
8
|
+
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :custodian_core_custody_repudiated_nodes, %i[custody_id node_id],
|
|
13
|
+
unique: true, name: "index_custody_repudiated_nodes_on_custody_and_node"
|
|
14
|
+
add_foreign_key :custodian_core_custody_repudiated_nodes, :custodian_core_custodies, column: :custody_id
|
|
15
|
+
add_foreign_key :custodian_core_custody_repudiated_nodes, :custodian_core_nodes, column: :node_id
|
|
16
|
+
end
|
|
17
|
+
end
|