change_requests 0.2.5 → 0.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 +4 -4
- data/CLAUDE.md +18 -0
- data/README.md +25 -0
- data/config/locales/en.yml +4 -6
- data/lib/change_requests/authorization/permissions.rb +10 -6
- data/lib/change_requests/commands/claim_execution.rb +112 -0
- data/lib/change_requests/commands/create.rb +5 -14
- data/lib/change_requests/commands/execute.rb +35 -0
- data/lib/change_requests/commands/override.rb +22 -0
- data/lib/change_requests/commands/settle_execution.rb +55 -0
- data/lib/change_requests/engine.rb +11 -0
- data/lib/change_requests/execution/dispatcher.rb +73 -0
- data/lib/change_requests/execution/runner.rb +70 -0
- data/lib/change_requests/execution/target_contract.rb +88 -0
- data/lib/change_requests/guards/base.rb +10 -4
- data/lib/change_requests/guards/execute.rb +40 -2
- data/lib/change_requests/models/quorum.rb +1 -1
- data/lib/change_requests/operation/override.rb +22 -0
- data/lib/change_requests/operation.rb +75 -55
- data/lib/change_requests/operations.rb +18 -0
- data/lib/change_requests/version.rb +1 -1
- data/lib/change_requests/workflow/builder.rb +112 -0
- data/lib/change_requests/workflow/quorum.rb +1 -1
- data/lib/change_requests/workflow/stage_builder.rb +108 -0
- data/lib/change_requests/workflow.rb +4 -0
- data/lib/change_requests.rb +10 -0
- data/lib/tasks/change_requests.rake +21 -0
- metadata +12 -1
|
@@ -4,8 +4,8 @@ module ChangeRequests
|
|
|
4
4
|
module Guards
|
|
5
5
|
# May this actor execute this request (§7.2, §8)?
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# The §8.1 override branch is M3a-5. `Execution::Runner` drives the claim behind
|
|
8
|
+
# `Commands::Execute`.
|
|
9
9
|
#
|
|
10
10
|
# Separation of duties is the one rule here with no counterpart in the other guards. `Approve`
|
|
11
11
|
# refuses the requester by identity and consults nothing (I5); `Execute` refuses them *unless*
|
|
@@ -17,8 +17,13 @@ module ChangeRequests
|
|
|
17
17
|
refuses_with NotExecutable
|
|
18
18
|
|
|
19
19
|
def refusal
|
|
20
|
+
return override_refusal if override?
|
|
21
|
+
|
|
20
22
|
# `successful` is final, and "already done" is a better answer than "not approved".
|
|
21
23
|
return :already_finalized if request.final?
|
|
24
|
+
# Ahead of :not_approved, which would be untrue of a request that is approved and mid-flight.
|
|
25
|
+
# T1's conditional UPDATE is the invariant beneath this, raising ExecutionInProgress (§8).
|
|
26
|
+
return :executing if request.executing?
|
|
22
27
|
return :not_approved unless EXECUTABLE_STATUSES.include?(request.status)
|
|
23
28
|
return :attempts_exhausted if request.failed? && !request.retryable?
|
|
24
29
|
return :not_permitted unless actor_type.may_execute
|
|
@@ -28,8 +33,41 @@ module ChangeRequests
|
|
|
28
33
|
nil
|
|
29
34
|
end
|
|
30
35
|
|
|
36
|
+
# §8.1, and deliberately not a variation on the branch above. An override is not a lenient
|
|
37
|
+
# execute: it asks whether this actor may suspend the gem's central promise on this action.
|
|
38
|
+
#
|
|
39
|
+
# Narrowed to `pending` on purpose. An approved or failed request executes by the ordinary
|
|
40
|
+
# path, so recording an override for it would put a badge, an event and an `overridden_at` on
|
|
41
|
+
# a request that needed none - and T1's conditional UPDATE takes `WHERE status = 'pending'`
|
|
42
|
+
# for an override, which would otherwise refuse it far less clearly.
|
|
43
|
+
def override_refusal
|
|
44
|
+
return :already_finalized if request.final?
|
|
45
|
+
return :executing if request.executing?
|
|
46
|
+
return :override_not_permitted unless operation.overridable?
|
|
47
|
+
return :not_pending unless request.pending?
|
|
48
|
+
return :not_permitted unless actor_type.may_execute
|
|
49
|
+
return :requester if requester? && !config.requester_may_override
|
|
50
|
+
return :override_not_permitted unless satisfies_override_permissions?
|
|
51
|
+
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def override?
|
|
56
|
+
options[:override] ? true : false
|
|
57
|
+
end
|
|
58
|
+
|
|
31
59
|
private
|
|
32
60
|
|
|
61
|
+
# An override declaring no permissions is open to anyone whose type may_execute, which the
|
|
62
|
+
# branch above has already established (§8.1).
|
|
63
|
+
def satisfies_override_permissions?
|
|
64
|
+
policy = operation.override_policy
|
|
65
|
+
|
|
66
|
+
return true if policy.unrestricted?
|
|
67
|
+
|
|
68
|
+
Authorization::Permissions.held_by(actor, actor_type).intersect?(policy.permissions)
|
|
69
|
+
end
|
|
70
|
+
|
|
33
71
|
def actor_type
|
|
34
72
|
config.actor_types.fetch(actor_ref[:type])
|
|
35
73
|
end
|
|
@@ -42,7 +42,7 @@ module ChangeRequests
|
|
|
42
42
|
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 1 },
|
|
43
43
|
uniqueness: { scope: :change_request_stage_id }
|
|
44
44
|
|
|
45
|
-
# Null when the stage holds exactly one quorum -
|
|
45
|
+
# Null when the stage holds exactly one quorum - `w.stage` without a block - because "which
|
|
46
46
|
# quorum" is then not a meaningful question (§5.9).
|
|
47
47
|
validates :name, format: { with: NAME_FORMAT }, allow_nil: true
|
|
48
48
|
validates :name, uniqueness: { scope: :change_request_stage_id }, allow_nil: true
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
class Operation
|
|
5
|
+
# §8.1's break-glass declaration: who may take it, and whether they must say why.
|
|
6
|
+
#
|
|
7
|
+
# op.override permissions: %w(security_officer), require_reason: true
|
|
8
|
+
#
|
|
9
|
+
# An empty permission list is a declaration, not an oversight: the override is then open to any
|
|
10
|
+
# actor whose registered type `may_execute`. §8.1 calls the override separately permissioned,
|
|
11
|
+
# and a host declaring none has said that its execute permission is the separation.
|
|
12
|
+
Override = Data.define(:permissions, :require_reason) do
|
|
13
|
+
def require_reason?
|
|
14
|
+
require_reason
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def unrestricted?
|
|
18
|
+
permissions.empty?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -5,24 +5,21 @@ module ChangeRequests
|
|
|
5
5
|
# workflow it materialises (§6.4). Declaring this is what buys the dispatch allowlist, the
|
|
6
6
|
# snapshot-on-create and the policy-not-caller-input guarantee of §6.12.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
8
|
+
# `op.cooldown` arrives with M9b.
|
|
9
9
|
class Operation
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
attr_reader :key, :workflow
|
|
14
|
-
attr_accessor :version, :service, :payload_labels, :idempotent
|
|
10
|
+
attr_reader :key, :override_policy
|
|
11
|
+
attr_accessor :version, :service, :payload_labels
|
|
15
12
|
attr_writer :method_name, :max_attempts
|
|
16
13
|
|
|
17
14
|
def initialize(key)
|
|
18
15
|
@key = key.to_s
|
|
19
16
|
@method_name = :call
|
|
20
|
-
@idempotent = false
|
|
21
17
|
@payload_labels = nil
|
|
22
18
|
@max_attempts = nil
|
|
23
19
|
@expires_in = nil
|
|
24
20
|
@expires_in_set = false
|
|
25
|
-
@
|
|
21
|
+
@override_policy = nil
|
|
22
|
+
@workflow = Workflow.new
|
|
26
23
|
end
|
|
27
24
|
|
|
28
25
|
def method_name
|
|
@@ -48,16 +45,29 @@ module ChangeRequests
|
|
|
48
45
|
@expires_in = value
|
|
49
46
|
end
|
|
50
47
|
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
48
|
+
# Without a block, the reader `Commands::Create` walks. With one, the §6.4 / §6.9 declaration -
|
|
49
|
+
# the only way to say who approves: ordered stages, each holding one inline quorum or a block of
|
|
50
|
+
# named ones.
|
|
51
|
+
#
|
|
52
|
+
# Replaces any previous description - it is "the approval rule for this operation", not one of
|
|
53
|
+
# several - and the new one is only installed once it has built without refusing.
|
|
54
|
+
def workflow(&block)
|
|
55
|
+
return @workflow unless block
|
|
56
|
+
|
|
57
|
+
@workflow = Workflow::Builder.build(operation_key: key, &block)
|
|
58
|
+
end
|
|
56
59
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
# §6.10's break-glass opt-in. Declared, never defaulted: no override exists until a host
|
|
61
|
+
# writes this line. The reader is `override_policy` rather than this method - with no block to
|
|
62
|
+
# disambiguate, one name cannot both declare and report, and a bare `op.override` that read
|
|
63
|
+
# instead of declaring would silently leave the gate shut.
|
|
64
|
+
def override(permissions: nil, require_reason: false)
|
|
65
|
+
@override_policy = Override.new(permissions: list(permissions).map(&:to_s),
|
|
66
|
+
require_reason: require_reason ? true : false)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def overridable?
|
|
70
|
+
!override_policy.nil?
|
|
61
71
|
end
|
|
62
72
|
|
|
63
73
|
def validate!
|
|
@@ -69,8 +79,20 @@ module ChangeRequests
|
|
|
69
79
|
"ChangeRequests operation #{key.inspect} is misconfigured:\n- #{problems.join("\n- ")}"
|
|
70
80
|
end
|
|
71
81
|
|
|
82
|
+
# Everything checkable without loading the host's classes. One implementation, three readers:
|
|
83
|
+
# `validate!` at declaration, `Commands::Create` at creation and `Operations#verify!` at boot,
|
|
84
|
+
# so none of them can disagree about what a complete declaration is (§7.2 †, §6.12 point 6).
|
|
72
85
|
def problems
|
|
73
|
-
[version_problem
|
|
86
|
+
[version_problem, service_problem, method_name_problem, workflow_problem,
|
|
87
|
+
*threshold_problems].compact
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# What needs the host's classes loaded, so it runs at boot and nowhere else: the §6.12 target
|
|
91
|
+
# contract, in the words `Execution::Dispatcher` would use for the same defect.
|
|
92
|
+
def target_problems
|
|
93
|
+
return [] if service.blank? || !method_name_declared?
|
|
94
|
+
|
|
95
|
+
[Execution::TargetContract.problem(service: service, method_name: method_name)].compact
|
|
74
96
|
end
|
|
75
97
|
|
|
76
98
|
private
|
|
@@ -82,59 +104,57 @@ module ChangeRequests
|
|
|
82
104
|
"every request as a NOT NULL column, and the gem never judges its content (§5.10)."
|
|
83
105
|
end
|
|
84
106
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
refuse_empty_eligibility if rows.empty? && actors.empty?
|
|
90
|
-
refuse_threshold(required) unless required.is_a?(Integer) && required >= 1
|
|
91
|
-
refuse_match(match) unless match.nil? || Configuration::PERMISSION_MATCHES.include?(match)
|
|
107
|
+
# Array() would splat a Struct or a Hash into its members. Only a real array is one.
|
|
108
|
+
def list(value)
|
|
109
|
+
return [] if value.nil?
|
|
92
110
|
|
|
93
|
-
|
|
94
|
-
permissions: rows, eligible_actors: actors)
|
|
111
|
+
Array.try_convert(value) || [value]
|
|
95
112
|
end
|
|
96
113
|
|
|
97
|
-
def
|
|
98
|
-
|
|
114
|
+
def service_problem
|
|
115
|
+
return if service.present?
|
|
99
116
|
|
|
100
|
-
|
|
101
|
-
if entries.empty? && actor_type
|
|
102
|
-
|
|
103
|
-
entries.map { |entry| permission_row(entry, actor_type) }
|
|
117
|
+
"it declares no service, so nothing could ever execute it - set `op.service`"
|
|
104
118
|
end
|
|
105
119
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
120
|
+
# `attr_writer :method_name` can assign the documented :call default away (§6.12). Reported
|
|
121
|
+
# here, so verify! names the declaration rather than respond_to? raising on nil.
|
|
122
|
+
def method_name_problem
|
|
123
|
+
return if method_name_declared?
|
|
109
124
|
|
|
110
|
-
|
|
111
|
-
|
|
125
|
+
"op.method_name is #{@method_name.inspect}. Dispatch calls the public singleton method of " \
|
|
126
|
+
"that name, and it defaults to :call - assigning nil takes the default away (§6.12)."
|
|
112
127
|
end
|
|
113
128
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
129
|
+
def method_name_declared?
|
|
130
|
+
method_name.to_s.strip.present?
|
|
131
|
+
end
|
|
117
132
|
|
|
118
|
-
|
|
133
|
+
def workflow_problem
|
|
134
|
+
return unless workflow.empty?
|
|
135
|
+
|
|
136
|
+
"it declares no approvals, so a request could never be approved - declare `op.workflow`"
|
|
119
137
|
end
|
|
120
138
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
139
|
+
# The DSL refuses these at declaration. Re-checked so one call reports everything, and so a
|
|
140
|
+
# hand-built description cannot enter the registry unnoticed.
|
|
141
|
+
def threshold_problems
|
|
142
|
+
workflow.stages.flat_map do |stage|
|
|
143
|
+
stage.quorums.filter_map { |quorum| threshold_problem(stage, quorum) }
|
|
144
|
+
end
|
|
126
145
|
end
|
|
127
146
|
|
|
128
|
-
def
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
147
|
+
def threshold_problem(stage, quorum)
|
|
148
|
+
return if quorum.threshold.is_a?(Integer) && quorum.threshold >= 1
|
|
149
|
+
|
|
150
|
+
"#{describe(stage, quorum)} has threshold #{quorum.threshold.inspect}. " \
|
|
151
|
+
"One approval is the minimum, not zero (§5.3)."
|
|
132
152
|
end
|
|
133
153
|
|
|
134
|
-
def
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
154
|
+
def describe(stage, quorum)
|
|
155
|
+
return "stage #{stage.name.to_sym.inspect}" if quorum.name.nil?
|
|
156
|
+
|
|
157
|
+
"stage #{stage.name.to_sym.inspect} quorum #{quorum.name.to_sym.inspect}"
|
|
138
158
|
end
|
|
139
159
|
end
|
|
140
160
|
end
|
|
@@ -41,6 +41,24 @@ module ChangeRequests
|
|
|
41
41
|
@operations.clear
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Boot-time verification (§6.12 point 6), run by `rake change_requests:verify` and by the
|
|
45
|
+
# engine's to_prepare hook in development. One raised error listing every problem, the shape
|
|
46
|
+
# `Configuration#validate!` already uses (Q4).
|
|
47
|
+
def verify!
|
|
48
|
+
problems = self.problems
|
|
49
|
+
|
|
50
|
+
return true if problems.empty?
|
|
51
|
+
|
|
52
|
+
fail ConfigurationError,
|
|
53
|
+
"ChangeRequests operations are misconfigured:\n- #{problems.join("\n- ")}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def problems
|
|
57
|
+
@operations.each_value.flat_map do |operation|
|
|
58
|
+
(operation.problems + operation.target_problems).map { |problem| "#{operation.key}: #{problem}" }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
44
62
|
private
|
|
45
63
|
|
|
46
64
|
def initialize_copy(source)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
class Workflow
|
|
5
|
+
# The `w` of `op.workflow do |w| … end` (§6.4, §6.9). It builds a description and nothing else:
|
|
6
|
+
# `Commands::Create` materialises whatever it is handed, and this is the only producer.
|
|
7
|
+
#
|
|
8
|
+
# Everything it refuses, it refuses at declaration time - in an initializer, where the mistake
|
|
9
|
+
# was made - rather than at creation time, where a unique index or a NOT NULL column would.
|
|
10
|
+
class Builder
|
|
11
|
+
def self.build(operation_key:)
|
|
12
|
+
builder = new(operation_key)
|
|
13
|
+
yield(builder)
|
|
14
|
+
|
|
15
|
+
builder.to_workflow
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(operation_key)
|
|
19
|
+
@operation_key = operation_key
|
|
20
|
+
@stages = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def stage(name, satisfied_by: :any_quorum, **quorum_options, &)
|
|
24
|
+
stage_name = normalized_name(name)
|
|
25
|
+
mode = normalized_satisfied_by(stage_name, satisfied_by)
|
|
26
|
+
builder = StageBuilder.new(operation_key: operation_key, stage_name: stage_name)
|
|
27
|
+
|
|
28
|
+
collect(builder, stage_name, quorum_options, &)
|
|
29
|
+
|
|
30
|
+
stages << Stage.new(name: stage_name, position: stages.size + 1, satisfied_by: mode,
|
|
31
|
+
quorums: builder.quorums)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_workflow
|
|
35
|
+
refuse_empty_workflow if stages.empty?
|
|
36
|
+
|
|
37
|
+
Workflow.new(stages)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
attr_reader :operation_key, :stages
|
|
43
|
+
|
|
44
|
+
def collect(builder, stage_name, quorum_options, &block)
|
|
45
|
+
return builder.quorum(**quorum_options) unless block
|
|
46
|
+
|
|
47
|
+
refuse_mixed(stage_name) if quorum_options.any?
|
|
48
|
+
|
|
49
|
+
block.call(builder)
|
|
50
|
+
|
|
51
|
+
refuse_empty_stage(stage_name) if builder.quorums.empty?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def normalized_name(name)
|
|
55
|
+
stage_name = name.to_s
|
|
56
|
+
|
|
57
|
+
refuse_nameless_stage(name) if stage_name.strip.empty?
|
|
58
|
+
refuse_duplicate_stage(stage_name) if stages.any? { |stage| stage.name == stage_name }
|
|
59
|
+
|
|
60
|
+
stage_name
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalized_satisfied_by(stage_name, satisfied_by)
|
|
64
|
+
mode = satisfied_by&.to_sym
|
|
65
|
+
|
|
66
|
+
refuse_satisfied_by(stage_name, satisfied_by) unless SATISFIED_BY.include?(mode)
|
|
67
|
+
|
|
68
|
+
mode
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def prefix
|
|
72
|
+
"ChangeRequests operation #{operation_key.inspect}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def refuse_nameless_stage(name)
|
|
76
|
+
fail ConfigurationError,
|
|
77
|
+
"#{prefix}: op.workflow stage #{name.inspect} needs a name. change_request_stages.name is " \
|
|
78
|
+
"NOT NULL, and the name is the identifier the timeline and the locale file read (§5.2, §5.9)."
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def refuse_duplicate_stage(stage_name)
|
|
82
|
+
fail ConfigurationError,
|
|
83
|
+
"#{prefix}: op.workflow declares stage #{stage_name.to_sym.inspect} twice. Stage names are " \
|
|
84
|
+
"unique within a request, and the unique index would refuse the second row (§5.2)."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def refuse_satisfied_by(stage_name, satisfied_by)
|
|
88
|
+
fail ConfigurationError,
|
|
89
|
+
"#{prefix}: op.workflow stage #{stage_name.to_sym.inspect} satisfied_by: #{satisfied_by.inspect}. " \
|
|
90
|
+
"Expected #{SATISFIED_BY.map(&:inspect).join(" or ")} (§5.2)."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def refuse_mixed(stage_name)
|
|
94
|
+
fail ConfigurationError,
|
|
95
|
+
"#{prefix}: op.workflow stage #{stage_name.to_sym.inspect} takes either a block of quorums or " \
|
|
96
|
+
"one inline quorum, not both. Move the inline keywords into a `q.quorum` (§6.9)."
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def refuse_empty_stage(stage_name)
|
|
100
|
+
fail ConfigurationError,
|
|
101
|
+
"#{prefix}: op.workflow stage #{stage_name.to_sym.inspect} declares no quorum. A stage nobody " \
|
|
102
|
+
"can satisfy would leave the request pending until it expired (§5.3)."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def refuse_empty_workflow
|
|
106
|
+
fail ConfigurationError,
|
|
107
|
+
"#{prefix}: op.workflow declares no stage, so a request against this operation could never " \
|
|
108
|
+
"be approved. Declare at least one `w.stage` (§6.4)."
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module ChangeRequests
|
|
4
4
|
class Workflow
|
|
5
5
|
# One counting rule of the described workflow (§5.3). `name` is null when its stage holds
|
|
6
|
-
# exactly one quorum -
|
|
6
|
+
# exactly one quorum - `w.stage` without a block (§5.9).
|
|
7
7
|
#
|
|
8
8
|
# `eligible_actors` holds the actor objects as the host declared them. Resolving them to
|
|
9
9
|
# (type, id) here would call `actor_attributes` from an initializer, before the file that
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
class Workflow
|
|
5
|
+
# The `q` of `w.stage :name do |q| … end`, and the one place a declared quorum becomes a
|
|
6
|
+
# Workflow::Quorum. The single-quorum stage shorthand routes through it too, so one
|
|
7
|
+
# normalisation and one set of refusals serve both forms (§5.3, §6.9).
|
|
8
|
+
class StageBuilder
|
|
9
|
+
attr_reader :quorums
|
|
10
|
+
|
|
11
|
+
def initialize(operation_key:, stage_name:)
|
|
12
|
+
@operation_key = operation_key
|
|
13
|
+
@stage_name = stage_name
|
|
14
|
+
@quorums = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def quorum(name = nil, permissions: nil, actor_type: nil, eligible_actors: nil, match: nil, threshold: 1)
|
|
18
|
+
quorum_name = name&.to_s
|
|
19
|
+
rows = permission_rows(permissions, actor_type)
|
|
20
|
+
actors = list(eligible_actors)
|
|
21
|
+
|
|
22
|
+
refuse_name(quorum_name)
|
|
23
|
+
refuse_empty_eligibility(quorum_name) if rows.empty? && actors.empty?
|
|
24
|
+
refuse_threshold(quorum_name, threshold) unless threshold.is_a?(Integer) && threshold >= 1
|
|
25
|
+
refuse_match(quorum_name, match) unless match.nil? || Configuration::PERMISSION_MATCHES.include?(match)
|
|
26
|
+
|
|
27
|
+
@quorums << Quorum.new(name: quorum_name, position: @quorums.size + 1, threshold: threshold,
|
|
28
|
+
permission_match: match, permissions: rows, eligible_actors: actors)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :operation_key, :stage_name
|
|
34
|
+
|
|
35
|
+
def permission_rows(permissions, actor_type)
|
|
36
|
+
entries = list(permissions)
|
|
37
|
+
|
|
38
|
+
return [Permission.new(permission: nil, actor_type: actor_type.to_s)] if entries.empty? && actor_type
|
|
39
|
+
|
|
40
|
+
entries.map { |entry| permission_row(entry, actor_type) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def permission_row(entry, actor_type)
|
|
44
|
+
return Permission.new(permission: entry.to_s, actor_type: actor_type&.to_s) unless entry.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
Permission.new(permission: entry[:permission]&.to_s,
|
|
47
|
+
actor_type: (entry[:actor_type] || actor_type)&.to_s)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Array() would splat a Struct or a Hash into its members. Only a real array is one.
|
|
51
|
+
def list(value)
|
|
52
|
+
return [] if value.nil?
|
|
53
|
+
|
|
54
|
+
Array.try_convert(value) || [value]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A nameless quorum is the single-quorum shorthand written out (§5.9). Once a stage holds a
|
|
58
|
+
# second one, "which quorum was satisfied" becomes a question the metadata has to answer.
|
|
59
|
+
def refuse_name(quorum_name)
|
|
60
|
+
return if quorums.empty? && quorum_name.nil?
|
|
61
|
+
|
|
62
|
+
refuse_unnamed if quorum_name.nil? || quorums.any? { |declared| declared.name.nil? }
|
|
63
|
+
refuse_duplicate(quorum_name) if quorums.any? { |declared| declared.name == quorum_name }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def context(quorum_name = nil)
|
|
67
|
+
return "op.workflow stage #{stage_name.to_sym.inspect}" if quorum_name.nil?
|
|
68
|
+
|
|
69
|
+
"op.workflow stage #{stage_name.to_sym.inspect} quorum #{quorum_name.to_sym.inspect}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def prefix
|
|
73
|
+
"ChangeRequests operation #{operation_key.inspect}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def refuse_unnamed
|
|
77
|
+
fail ConfigurationError,
|
|
78
|
+
"#{prefix}: #{context} holds more than one quorum, so each of them needs a name. " \
|
|
79
|
+
"That name is what a stage_satisfied event reports and what a test matcher asks for (§5.9)."
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def refuse_duplicate(quorum_name)
|
|
83
|
+
fail ConfigurationError,
|
|
84
|
+
"#{prefix}: #{context} declares quorum #{quorum_name.to_sym.inspect} twice. Quorum names are " \
|
|
85
|
+
"unique within their stage, and the unique index would refuse the second row (§5.3)."
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def refuse_empty_eligibility(quorum_name)
|
|
89
|
+
fail ConfigurationError,
|
|
90
|
+
"#{prefix}: #{context(quorum_name)} needs `permissions:`, `actor_type:` or " \
|
|
91
|
+
"`eligible_actors:`. A quorum nobody qualifies for can never be satisfied, and the " \
|
|
92
|
+
"request would sit pending until it expired (§5.3)."
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def refuse_threshold(quorum_name, threshold)
|
|
96
|
+
fail ConfigurationError,
|
|
97
|
+
"#{prefix}: #{context(quorum_name)} threshold: #{threshold.inspect}. " \
|
|
98
|
+
"It must be an integer of at least 1 - one approval is the minimum, not zero."
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def refuse_match(quorum_name, match)
|
|
102
|
+
fail ConfigurationError,
|
|
103
|
+
"#{prefix}: #{context(quorum_name)} match: #{match.inspect}. " \
|
|
104
|
+
"Expected #{Configuration::PERMISSION_MATCHES.map(&:inspect).join(" or ")} (§5.3)."
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -8,6 +8,10 @@ module ChangeRequests
|
|
|
8
8
|
# are the frozen snapshot - editing an operation afterwards never reaches an in-flight request
|
|
9
9
|
# (§6.12 point 4).
|
|
10
10
|
class Workflow
|
|
11
|
+
# The declaration-side vocabulary of change_request_stages.satisfied_by, as
|
|
12
|
+
# Configuration::PERMISSION_MATCHES is for the quorum column (§5.2).
|
|
13
|
+
SATISFIED_BY = %i(any_quorum all_quorums).freeze
|
|
14
|
+
|
|
11
15
|
attr_reader :stages
|
|
12
16
|
|
|
13
17
|
def initialize(stages = [])
|
data/lib/change_requests.rb
CHANGED
|
@@ -49,6 +49,14 @@ module ChangeRequests
|
|
|
49
49
|
{ type: type, id: actor.id.to_s, label: registered.label.call(actor).to_s }
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# The host-facing entry point (§6.5), wrapping Commands::Create. The key is positional and the
|
|
53
|
+
# rest are keywords because this is the call every host writes, and it reads better that way.
|
|
54
|
+
# It adds nothing else: the errors are Create's, unrescued.
|
|
55
|
+
def request!(operation_key, requester:, payload: {}, tenant: nil)
|
|
56
|
+
Commands::Create.call(operation_key: operation_key, requester: requester,
|
|
57
|
+
payload: payload, tenant: tenant)
|
|
58
|
+
end
|
|
59
|
+
|
|
52
60
|
def configure
|
|
53
61
|
yield(config)
|
|
54
62
|
|
|
@@ -75,7 +83,9 @@ module ChangeRequests
|
|
|
75
83
|
def setup_loader
|
|
76
84
|
@loader = Zeitwerk::Loader.for_gem.tap do |loader|
|
|
77
85
|
# Generators go through Rails' generator lookup; the test kit is required by the host.
|
|
86
|
+
# lib/tasks holds .rake files the engine loads, not constants under a Tasks namespace.
|
|
78
87
|
loader.ignore("#{__dir__}/generators")
|
|
88
|
+
loader.ignore("#{__dir__}/tasks")
|
|
79
89
|
loader.ignore("#{__dir__}/change_requests/rspec.rb")
|
|
80
90
|
|
|
81
91
|
# Filing convention, not a namespace: models/request.rb defines ChangeRequests::Request.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded by the engine's lib/tasks path, so a host gets these from its own `Rails.application
|
|
4
|
+
# .load_tasks` with nothing to require. M3b adds the maintenance sweepers to this namespace.
|
|
5
|
+
namespace :change_requests do
|
|
6
|
+
desc "Verify every declared operation: its version, target and workflow (§6.12 point 6)"
|
|
7
|
+
task verify: :environment do
|
|
8
|
+
count = ChangeRequests.operations.keys.size
|
|
9
|
+
|
|
10
|
+
begin
|
|
11
|
+
ChangeRequests.operations.verify!
|
|
12
|
+
rescue ChangeRequests::ConfigurationError => e
|
|
13
|
+
warn e.message
|
|
14
|
+
|
|
15
|
+
# Non-zero so CI fails on it. `exit` rather than `abort`, whose message would repeat e.
|
|
16
|
+
exit 1
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
puts "ChangeRequests: #{count} #{"operation".pluralize(count)} verified."
|
|
20
|
+
end
|
|
21
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: change_requests
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andreas Finger
|
|
@@ -339,11 +339,15 @@ files:
|
|
|
339
339
|
- lib/change_requests/commands/approve.rb
|
|
340
340
|
- lib/change_requests/commands/base.rb
|
|
341
341
|
- lib/change_requests/commands/cancel.rb
|
|
342
|
+
- lib/change_requests/commands/claim_execution.rb
|
|
342
343
|
- lib/change_requests/commands/comment.rb
|
|
343
344
|
- lib/change_requests/commands/create.rb
|
|
344
345
|
- lib/change_requests/commands/evaluate_workflow.rb
|
|
346
|
+
- lib/change_requests/commands/execute.rb
|
|
345
347
|
- lib/change_requests/commands/expire.rb
|
|
348
|
+
- lib/change_requests/commands/override.rb
|
|
346
349
|
- lib/change_requests/commands/reject.rb
|
|
350
|
+
- lib/change_requests/commands/settle_execution.rb
|
|
347
351
|
- lib/change_requests/commands/unapprove.rb
|
|
348
352
|
- lib/change_requests/configuration.rb
|
|
349
353
|
- lib/change_requests/configuration/actor_type.rb
|
|
@@ -351,6 +355,9 @@ files:
|
|
|
351
355
|
- lib/change_requests/configuration/tenant_type.rb
|
|
352
356
|
- lib/change_requests/engine.rb
|
|
353
357
|
- lib/change_requests/errors.rb
|
|
358
|
+
- lib/change_requests/execution/dispatcher.rb
|
|
359
|
+
- lib/change_requests/execution/runner.rb
|
|
360
|
+
- lib/change_requests/execution/target_contract.rb
|
|
354
361
|
- lib/change_requests/guards/.keep
|
|
355
362
|
- lib/change_requests/guards/approve.rb
|
|
356
363
|
- lib/change_requests/guards/base.rb
|
|
@@ -377,17 +384,21 @@ files:
|
|
|
377
384
|
- lib/change_requests/models/request.rb
|
|
378
385
|
- lib/change_requests/models/stage.rb
|
|
379
386
|
- lib/change_requests/operation.rb
|
|
387
|
+
- lib/change_requests/operation/override.rb
|
|
380
388
|
- lib/change_requests/operations.rb
|
|
381
389
|
- lib/change_requests/presenters/.keep
|
|
382
390
|
- lib/change_requests/testing.rb
|
|
383
391
|
- lib/change_requests/translation.rb
|
|
384
392
|
- lib/change_requests/version.rb
|
|
385
393
|
- lib/change_requests/workflow.rb
|
|
394
|
+
- lib/change_requests/workflow/builder.rb
|
|
386
395
|
- lib/change_requests/workflow/permission.rb
|
|
387
396
|
- lib/change_requests/workflow/quorum.rb
|
|
388
397
|
- lib/change_requests/workflow/stage.rb
|
|
398
|
+
- lib/change_requests/workflow/stage_builder.rb
|
|
389
399
|
- lib/generators/.keep
|
|
390
400
|
- lib/generators/change_requests/install/templates/migration.rb.tt
|
|
401
|
+
- lib/tasks/change_requests.rake
|
|
391
402
|
- sig/change_requests.rbs
|
|
392
403
|
homepage: https://github.com/mediafinger/change_requests
|
|
393
404
|
licenses:
|