change_requests 0.2.0 → 0.2.2
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/README.md +5 -0
- data/docs/adr/0001-headless-domain-core.md +40 -0
- data/docs/adr/0002-mountable-engine-with-isolated-namespace.md +38 -0
- data/docs/adr/0003-actor-references-as-triples.md +43 -0
- data/docs/adr/0004-uuid-primary-keys.md +35 -0
- data/docs/adr/0005-string-states-with-check-constraints.md +43 -0
- data/docs/adr/0006-creation-time-immutability.md +39 -0
- data/docs/adr/0007-append-only-audit-trail.md +42 -0
- data/docs/adr/0008-staged-multi-quorum-schema.md +43 -0
- data/docs/adr/0009-host-owned-schema.md +43 -0
- data/docs/adr/0010-operations-must-be-declared.md +57 -0
- data/docs/adr/0011-system-sentinel-actor.md +36 -0
- data/docs/adr/0012-declared-error-taxonomy.md +40 -0
- data/docs/adr/0013-json-runtime-pin.md +37 -0
- data/docs/adr/0014-executable-architecture-rules.md +45 -0
- data/docs/adr/README.md +25 -0
- data/lib/change_requests/authorization/callable.rb +30 -0
- data/lib/change_requests/authorization/permissions.rb +66 -1
- data/lib/change_requests/commands/approve.rb +69 -0
- data/lib/change_requests/commands/base.rb +113 -0
- data/lib/change_requests/commands/cancel.rb +43 -0
- data/lib/change_requests/commands/comment.rb +40 -0
- data/lib/change_requests/commands/create.rb +169 -0
- data/lib/change_requests/commands/expire.rb +27 -0
- data/lib/change_requests/commands/reject.rb +77 -0
- data/lib/change_requests/commands/unapprove.rb +49 -0
- data/lib/change_requests/configuration.rb +22 -1
- data/lib/change_requests/errors.rb +45 -26
- data/lib/change_requests/guards/approve.rb +31 -0
- data/lib/change_requests/guards/base.rb +201 -0
- data/lib/change_requests/guards/cancel.rb +32 -0
- data/lib/change_requests/guards/comment.rb +32 -0
- data/lib/change_requests/guards/execute.rb +48 -0
- data/lib/change_requests/guards/expire.rb +37 -0
- data/lib/change_requests/guards/reject.rb +38 -0
- data/lib/change_requests/guards/unapprove.rb +41 -0
- data/lib/change_requests/models/approval.rb +1 -1
- data/lib/change_requests/models/attempt.rb +3 -1
- data/lib/change_requests/models/concerns/actor_columns.rb +12 -6
- data/lib/change_requests/models/event.rb +3 -1
- data/lib/change_requests/models/quorum.rb +7 -2
- data/lib/change_requests/models/request.rb +11 -2
- data/lib/change_requests/models/stage.rb +10 -3
- data/lib/change_requests/operation.rb +6 -2
- data/lib/change_requests/operations.rb +14 -4
- data/lib/change_requests/translation.rb +3 -1
- data/lib/change_requests/version.rb +1 -1
- data/lib/change_requests/workflow.rb +3 -1
- data/lib/change_requests.rb +9 -3
- data/lib/generators/change_requests/install/templates/migration.rb.tt +5 -0
- metadata +33 -1
|
@@ -12,7 +12,9 @@ module ChangeRequests
|
|
|
12
12
|
attr_accessor :actor_label_strategy, :actor_identity
|
|
13
13
|
|
|
14
14
|
# Authorization (§9.2)
|
|
15
|
-
|
|
15
|
+
attr_reader :authorization
|
|
16
|
+
# The default only; every quorum carries its own (§5.3).
|
|
17
|
+
attr_accessor :default_permission_match
|
|
16
18
|
|
|
17
19
|
# Separation of duties (§8, §8.1)
|
|
18
20
|
attr_accessor :approver_may_execute, :requester_may_execute, :requester_may_override
|
|
@@ -39,6 +41,16 @@ module ChangeRequests
|
|
|
39
41
|
@default_expires_in = nil
|
|
40
42
|
end
|
|
41
43
|
|
|
44
|
+
# §9.2 tells hosts to assign a bare lambda; the gem needs one object answering `allows?`.
|
|
45
|
+
def authorization=(policy)
|
|
46
|
+
@authorization =
|
|
47
|
+
if policy.respond_to?(:allows?) || !policy.respond_to?(:call)
|
|
48
|
+
policy # validate! reports anything that answers neither
|
|
49
|
+
else
|
|
50
|
+
Authorization::Callable.new(policy)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
42
54
|
# Reopens an existing registration rather than replacing it: two initializers may each
|
|
43
55
|
# contribute part of one.
|
|
44
56
|
def actor_type(name)
|
|
@@ -64,6 +76,7 @@ module ChangeRequests
|
|
|
64
76
|
label_strategy_problem,
|
|
65
77
|
permission_match_problem,
|
|
66
78
|
actor_identity_problem,
|
|
79
|
+
authorization_problem,
|
|
67
80
|
max_attempts_problem,
|
|
68
81
|
*actor_types.values.flat_map(&:problems),
|
|
69
82
|
*tenant_types.values.flat_map(&:problems),
|
|
@@ -117,6 +130,14 @@ module ChangeRequests
|
|
|
117
130
|
"such as `->(actor) { actor.person_id }` (§9.4)."
|
|
118
131
|
end
|
|
119
132
|
|
|
133
|
+
def authorization_problem
|
|
134
|
+
return if authorization.respond_to?(:allows?)
|
|
135
|
+
|
|
136
|
+
"config.authorization is #{authorization.inspect}. Expected " \
|
|
137
|
+
"ChangeRequests::Authorization::Permissions.new, another object answering " \
|
|
138
|
+
"`allows?(actor:, quorum:)`, or a lambda taking (actor:, request:, stage:, action:) (§9.2)."
|
|
139
|
+
end
|
|
140
|
+
|
|
120
141
|
def max_attempts_problem
|
|
121
142
|
return if default_max_attempts.is_a?(Integer) && default_max_attempts >= 1
|
|
122
143
|
|
|
@@ -22,31 +22,17 @@ module ChangeRequests
|
|
|
22
22
|
# └── StaleRequest (optimistic lock conflict)
|
|
23
23
|
class Error < StandardError; end
|
|
24
24
|
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# Raised before the type string is constantized, so a typo fails at creation, not at render time.
|
|
29
|
-
class UnknownActorType < Error; end
|
|
30
|
-
|
|
31
|
-
# No live declaration for the request's operation_key. Every guard refuses except Comment (§5.11).
|
|
32
|
-
class UnknownOperation < Error; end
|
|
33
|
-
|
|
34
|
-
# Payload is not a JSON object. Matching it to the target's signature is the host's job (§6.12).
|
|
35
|
-
class InvalidPayload < Error; end
|
|
36
|
-
|
|
37
|
-
# A creation-time fact was reassigned. Raised by Concerns::ReadonlyAttributes, not attr_readonly.
|
|
38
|
-
class ReadonlyAttribute < Error; end
|
|
39
|
-
|
|
40
|
-
# The actor may never do this. "Not yet" is a TransitionError.
|
|
41
|
-
class NotAuthorized < Error; end
|
|
42
|
-
|
|
43
|
-
# A transition the request will not accept (§7.2). Carries #request and #reason:
|
|
25
|
+
# What every refusal a guard raises carries (§7). `reason` is the contract - controllers branch on
|
|
26
|
+
# it, views render it as a disabled button's tooltip. The message is for humans.
|
|
44
27
|
#
|
|
45
28
|
# fail NotApprovable.new(request:, reason: :already_decided)
|
|
46
29
|
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
class
|
|
30
|
+
# A module rather than a base class, because §8 keeps NotAuthorized a *sibling* of the
|
|
31
|
+
# TransitionError family: "the actor may never do this" is a different answer from "not yet", and
|
|
32
|
+
# a host rescues them apart. Guards::Base#check! builds whichever class a guard declared with the
|
|
33
|
+
# same two keywords, so both have to accept them - without this, Ruby folds the keywords into the
|
|
34
|
+
# message and the reason is silently lost.
|
|
35
|
+
module Refusal
|
|
50
36
|
# Shared with the guards, so a disabled button and a raised error cannot word :not_pending
|
|
51
37
|
# differently. M1b-13 ships the translations.
|
|
52
38
|
I18N_SCOPE = "change_requests.errors"
|
|
@@ -60,11 +46,19 @@ module ChangeRequests
|
|
|
60
46
|
super(message || translated_message)
|
|
61
47
|
end
|
|
62
48
|
|
|
63
|
-
def i18n_key
|
|
49
|
+
def i18n_key
|
|
50
|
+
"#{I18N_SCOPE}.#{reason || self.class.error_key}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.included(base)
|
|
54
|
+
base.extend(ClassMethods)
|
|
55
|
+
end
|
|
64
56
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
57
|
+
module ClassMethods
|
|
58
|
+
# NotApprovable => "not_approvable". Used when a caller raised without a reason.
|
|
59
|
+
def error_key
|
|
60
|
+
name.split("::").last.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
61
|
+
end
|
|
68
62
|
end
|
|
69
63
|
|
|
70
64
|
private
|
|
@@ -76,6 +70,31 @@ module ChangeRequests
|
|
|
76
70
|
end
|
|
77
71
|
end
|
|
78
72
|
|
|
73
|
+
# Raised by config.validate! and operations.verify!. Message lists every problem found at once.
|
|
74
|
+
class ConfigurationError < Error; end
|
|
75
|
+
|
|
76
|
+
# Raised before the type string is constantized, so a typo fails at creation, not at render time.
|
|
77
|
+
class UnknownActorType < Error; end
|
|
78
|
+
|
|
79
|
+
# No live declaration for the request's operation_key. Every guard refuses except Comment (§5.11).
|
|
80
|
+
class UnknownOperation < Error; end
|
|
81
|
+
|
|
82
|
+
# Payload is not a JSON object. Matching it to the target's signature is the host's job (§6.12).
|
|
83
|
+
class InvalidPayload < Error; end
|
|
84
|
+
|
|
85
|
+
# A creation-time fact was reassigned. Raised by Concerns::ReadonlyAttributes, not attr_readonly.
|
|
86
|
+
class ReadonlyAttribute < Error; end
|
|
87
|
+
|
|
88
|
+
# The actor may never do this. "Not yet" is a TransitionError.
|
|
89
|
+
class NotAuthorized < Error
|
|
90
|
+
include Refusal
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A transition the request will not accept (§7.2).
|
|
94
|
+
class TransitionError < Error
|
|
95
|
+
include Refusal
|
|
96
|
+
end
|
|
97
|
+
|
|
79
98
|
class NotApprovable < TransitionError; end
|
|
80
99
|
class NotUnapprovable < TransitionError; end
|
|
81
100
|
class NotRejectable < TransitionError; end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Guards
|
|
5
|
+
# May this actor approve this request right now (§7, §7.2)?
|
|
6
|
+
#
|
|
7
|
+
# The branch order is the contract: it decides which reason a user sees, and "not your turn yet"
|
|
8
|
+
# is a different answer from "you cannot approve this at all".
|
|
9
|
+
class Approve < Base
|
|
10
|
+
refuses_with NotApprovable
|
|
11
|
+
|
|
12
|
+
def refusal
|
|
13
|
+
return :not_pending unless request.pending?
|
|
14
|
+
# Never configurable. There is no config.requester_may_approve to read, in any form (I5).
|
|
15
|
+
return :requester if same_person?(request.requester, actor)
|
|
16
|
+
return :stage_not_current if eligible_quorums.empty? && eligible_on_another_stage?
|
|
17
|
+
return :already_decided if already_decided?
|
|
18
|
+
return :not_permitted if eligible_quorums.empty?
|
|
19
|
+
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The subset an approval actually links to. Equal in M1; M9a makes it a strict subset under
|
|
24
|
+
# all_quorums, where an approval links to exactly one quorum - the lowest-position one the
|
|
25
|
+
# actor qualifies for - so one person cannot close two quorums that must both be met (§5.3).
|
|
26
|
+
def countable_quorums
|
|
27
|
+
eligible_quorums
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Guards
|
|
5
|
+
# One guard object, consulted by both the command and the presenter, so a disabled button and a
|
|
6
|
+
# raised error cannot disagree about why something is refused (§7).
|
|
7
|
+
#
|
|
8
|
+
# class Approve < Base
|
|
9
|
+
# refuses_with NotApprovable
|
|
10
|
+
#
|
|
11
|
+
# def refusal
|
|
12
|
+
# return :not_pending unless request.pending?
|
|
13
|
+
# ...
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Subclasses implement `refusal` and return nil when they permit the transition. The
|
|
18
|
+
# undeclared-operation check runs first, here, so no guard has to repeat it (§5.11).
|
|
19
|
+
class Base
|
|
20
|
+
# The shared vocabulary. `reason` is the contract - controllers branch on these symbols and
|
|
21
|
+
# views render them as a disabled button's tooltip - so a guard picks one from this list
|
|
22
|
+
# rather than inventing wording. M1b-13 translates every entry.
|
|
23
|
+
REASONS = %i(
|
|
24
|
+
operation_undeclared
|
|
25
|
+
not_pending
|
|
26
|
+
requester
|
|
27
|
+
stage_not_current
|
|
28
|
+
stage_not_open
|
|
29
|
+
already_decided
|
|
30
|
+
not_the_approver
|
|
31
|
+
not_permitted
|
|
32
|
+
already_finalized
|
|
33
|
+
executing
|
|
34
|
+
reason_required
|
|
35
|
+
body_required
|
|
36
|
+
may_not_request
|
|
37
|
+
not_approved
|
|
38
|
+
attempts_exhausted
|
|
39
|
+
not_expired
|
|
40
|
+
not_expirable
|
|
41
|
+
not_system
|
|
42
|
+
).freeze
|
|
43
|
+
|
|
44
|
+
attr_reader :request, :actor, :options
|
|
45
|
+
|
|
46
|
+
class_attribute :declared_error_class, instance_accessor: false
|
|
47
|
+
class_attribute :exempt_from_undeclared_operation, instance_accessor: false, default: false
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
# Declared, never derived from the guard's name: Create, Comment and Expire refuse with
|
|
51
|
+
# NotAuthorized, the rest with their own TransitionError.
|
|
52
|
+
def refuses_with(error_class)
|
|
53
|
+
self.declared_error_class = error_class
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def error_class
|
|
57
|
+
declared_error_class ||
|
|
58
|
+
fail(ConfigurationError, "#{name} must declare `refuses_with <error class>` (§7)")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Comment only. A request stranded by a removed declaration is exactly the one someone needs
|
|
62
|
+
# to leave a note on, and a comment writes no lifecycle state (§5.11, I8).
|
|
63
|
+
def exempt_from_undeclared_operation!
|
|
64
|
+
self.exempt_from_undeclared_operation = true
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(request:, actor:, **options)
|
|
69
|
+
@request = request
|
|
70
|
+
@actor = actor
|
|
71
|
+
@options = options
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def allowed?
|
|
75
|
+
reason.nil?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def reason
|
|
79
|
+
return :operation_undeclared if operation.nil? && !self.class.exempt_from_undeclared_operation
|
|
80
|
+
|
|
81
|
+
refusal
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# One shared rule beside the declared class: a request that is already over is the same
|
|
85
|
+
# refusal whichever command met it, and the model's TerminalStateGuard raises exactly this
|
|
86
|
+
# with exactly this reason (§5.8). A host rescuing AlreadyFinalized catches both.
|
|
87
|
+
REASON_ERRORS = { already_finalized: AlreadyFinalized }.freeze
|
|
88
|
+
|
|
89
|
+
def check!
|
|
90
|
+
return request if allowed?
|
|
91
|
+
|
|
92
|
+
fail error_for(reason).new(request: request, reason: reason)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Subclasses override. nil permits.
|
|
96
|
+
def refusal
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def config
|
|
101
|
+
ChangeRequests.config
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Resolved live, never from the columns on the row: those are audit data, and a request whose
|
|
105
|
+
# operation is no longer declared can never run (§6.12).
|
|
106
|
+
def operation
|
|
107
|
+
ChangeRequests.operations[request.operation_key]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def stage
|
|
111
|
+
request.current_stage
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Pluggable: the permission rows by default, or the host's own policy (§9.2).
|
|
115
|
+
def authorization
|
|
116
|
+
config.authorization
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The quorums of the current stage this actor qualifies for - the same predicate
|
|
120
|
+
# Request.awaiting_approval_from runs in SQL (§5.3). Shared by every guard that asks whether
|
|
121
|
+
# someone is an eligible approver: Approve, Reject, Cancel and Comment.
|
|
122
|
+
def eligible_quorums
|
|
123
|
+
@eligible_quorums ||= qualifying(stage&.quorums&.pending)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# §7.2's preamble: "eligible approver" means eligible for at least one quorum on *any* stage of
|
|
127
|
+
# this request, by permission or by name. A stage-three director may cancel or comment on a
|
|
128
|
+
# request sitting in stage one. Approve and Reject are the narrower, current-stage question.
|
|
129
|
+
def eligible_approver?
|
|
130
|
+
eligible_quorums.any? || eligible_on_another_stage?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# §6.9: a stage-three director sitting on a stage-one request is told to wait, not refused.
|
|
134
|
+
def eligible_on_another_stage?
|
|
135
|
+
request.stages.where.not(id: stage&.id).any? { |other| qualifying(other.quorums).any? }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# One decision per stage per person. When the host declares a shared identity it is used in
|
|
139
|
+
# place of (type, id), so one human cannot decide twice through two actor classes (§9.4).
|
|
140
|
+
def already_decided?
|
|
141
|
+
return false if stage.nil?
|
|
142
|
+
|
|
143
|
+
decided_by_reference? || decided_by_identity?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The acting actor as the columns store them. Raises UnknownActorType for an unregistered
|
|
147
|
+
# class, which is the allowlist doing its job (§9.1).
|
|
148
|
+
def actor_ref
|
|
149
|
+
@actor_ref ||= ChangeRequests.actor_attributes(actor)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Is this the same human twice? `(type, id)` is airtight within one actor class and blind
|
|
153
|
+
# across them, which `config.actor_identity` is the opt-in fix for (§9.4). Either side may be
|
|
154
|
+
# a live actor object or a stored reference triple.
|
|
155
|
+
def same_person?(one, other)
|
|
156
|
+
return false if one.nil? || other.nil?
|
|
157
|
+
|
|
158
|
+
left = identity_of(one)
|
|
159
|
+
right = identity_of(other)
|
|
160
|
+
|
|
161
|
+
return left == right if left && right
|
|
162
|
+
|
|
163
|
+
reference_of(one) == reference_of(other)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def error_for(reason)
|
|
169
|
+
REASON_ERRORS.fetch(reason) { self.class.error_class }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def qualifying(quorums)
|
|
173
|
+
return [] if quorums.nil?
|
|
174
|
+
|
|
175
|
+
quorums.select { |quorum| authorization.allows?(actor: actor, quorum: quorum) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def decided_by_reference?
|
|
179
|
+
stage.approvals.exists?(approver_type: actor_ref[:type], approver_id: actor_ref[:id])
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def decided_by_identity?
|
|
183
|
+
identity = identity_of(actor)
|
|
184
|
+
|
|
185
|
+
identity.present? && stage.approvals.exists?(approver_identity: identity)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def identity_of(subject)
|
|
189
|
+
return subject[:identity] if subject.is_a?(Hash)
|
|
190
|
+
|
|
191
|
+
config.actor_identity&.call(subject)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def reference_of(subject)
|
|
195
|
+
return subject.slice(:type, :id) if subject.is_a?(Hash)
|
|
196
|
+
|
|
197
|
+
ChangeRequests.actor_attributes(subject).slice(:type, :id)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Guards
|
|
5
|
+
# May this actor call the whole thing off (§7.2)?
|
|
6
|
+
#
|
|
7
|
+
# The requester, or any eligible approver - eligible for a quorum on *any* stage, not just the
|
|
8
|
+
# current one, per §7.2's preamble. Cancelling is a judgement about the request as a whole, not
|
|
9
|
+
# about the step it happens to be sitting on.
|
|
10
|
+
#
|
|
11
|
+
# The reason is mandatory and `Commands::Cancel` enforces it, for the reason Reject does (Q25).
|
|
12
|
+
class Cancel < Base
|
|
13
|
+
refuses_with NotCancelable
|
|
14
|
+
|
|
15
|
+
def refusal
|
|
16
|
+
return :already_finalized if request.final?
|
|
17
|
+
# The target is mid-flight. A status change cannot recall it, and setting a terminal status
|
|
18
|
+
# would leave the execution unable to record its own outcome (Q28, §8).
|
|
19
|
+
return :executing if request.executing?
|
|
20
|
+
return :not_permitted unless requester? || eligible_approver?
|
|
21
|
+
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def requester?
|
|
28
|
+
same_person?(request.requester, actor)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Guards
|
|
5
|
+
# May this actor leave a note (§7.2)?
|
|
6
|
+
#
|
|
7
|
+
# The requester, or any eligible approver - eligible for a quorum on *any* stage, like Cancel.
|
|
8
|
+
# Beyond that it refuses nothing: every final status, and a request whose operation is no longer
|
|
9
|
+
# declared, both stay open to comment. A comment writes no request column, so the terminal-state
|
|
10
|
+
# guard is never in its way, and post-mortem notes on a finished request are the point of an
|
|
11
|
+
# audit trail (§5.5).
|
|
12
|
+
#
|
|
13
|
+
# This is the one guard exempt from the undeclared-operation refusal (§5.11, I8): a request
|
|
14
|
+
# stranded by a removed declaration is exactly the one somebody needs to leave a note on.
|
|
15
|
+
class Comment < Base
|
|
16
|
+
refuses_with NotAuthorized
|
|
17
|
+
exempt_from_undeclared_operation!
|
|
18
|
+
|
|
19
|
+
def refusal
|
|
20
|
+
return :not_permitted unless requester? || eligible_approver?
|
|
21
|
+
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def requester?
|
|
28
|
+
same_person?(request.requester, actor)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Guards
|
|
5
|
+
# May this actor execute this request (§7.2, §8)?
|
|
6
|
+
#
|
|
7
|
+
# **Guard only.** `Commands::Execute`, the claim-then-invoke machinery and the §8.1 override
|
|
8
|
+
# branch are M3a (decision D2). The guard's inputs are all M1 state; the claim is not.
|
|
9
|
+
#
|
|
10
|
+
# Separation of duties is the one rule here with no counterpart in the other guards. `Approve`
|
|
11
|
+
# refuses the requester by identity and consults nothing (I5); `Execute` refuses them *unless*
|
|
12
|
+
# `config.requester_may_execute`, which defaults to false. Both use `same_person?`, so "is this
|
|
13
|
+
# the same human" is answered identically - including through `config.actor_identity` (§9.4).
|
|
14
|
+
class Execute < Base
|
|
15
|
+
EXECUTABLE_STATUSES = %w(approved failed).freeze
|
|
16
|
+
|
|
17
|
+
refuses_with NotExecutable
|
|
18
|
+
|
|
19
|
+
def refusal
|
|
20
|
+
# `successful` is final, and "already done" is a better answer than "not approved".
|
|
21
|
+
return :already_finalized if request.final?
|
|
22
|
+
return :not_approved unless EXECUTABLE_STATUSES.include?(request.status)
|
|
23
|
+
return :attempts_exhausted if request.failed? && !request.retryable?
|
|
24
|
+
return :not_permitted unless actor_type.may_execute
|
|
25
|
+
return :requester if requester? && !config.requester_may_execute
|
|
26
|
+
return :not_permitted if approver? && !config.approver_may_execute
|
|
27
|
+
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def actor_type
|
|
34
|
+
config.actor_types.fetch(actor_ref[:type])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def requester?
|
|
38
|
+
same_person?(request.requester, actor)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Who actually decided, not who was merely eligible to: `approver_may_execute` is about
|
|
42
|
+
# having spent a decision on this request, and it defaults to true.
|
|
43
|
+
def approver?
|
|
44
|
+
request.approvals.exists?(approver_type: actor_ref[:type], approver_id: actor_ref[:id])
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Guards
|
|
5
|
+
# May this request be expired right now (§7.2, §8)?
|
|
6
|
+
#
|
|
7
|
+
# System only. Expiry has no human behind it, so an actor being supplied at all is the refusal -
|
|
8
|
+
# nobody expires a request on purpose, the clock does.
|
|
9
|
+
#
|
|
10
|
+
# Every refusal here is NotAuthorized rather than a TransitionError, apart from the shared
|
|
11
|
+
# :already_finalized mapping. The only caller is M3b's `Maintenance.expire_stale!`, whose query
|
|
12
|
+
# already filters on status and `expires_at`, so these branches are a floor beneath it and never
|
|
13
|
+
# a user-facing flash (Q32).
|
|
14
|
+
class Expire < Base
|
|
15
|
+
EXPIRABLE_STATUSES = %w(pending approved).freeze
|
|
16
|
+
|
|
17
|
+
refuses_with NotAuthorized
|
|
18
|
+
|
|
19
|
+
def refusal
|
|
20
|
+
return :not_system unless actor.nil?
|
|
21
|
+
return :already_finalized if request.final?
|
|
22
|
+
return :not_expirable unless EXPIRABLE_STATUSES.include?(request.status)
|
|
23
|
+
return :not_expired unless expired?
|
|
24
|
+
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# A null `expires_at` never expires (§5.1), which is the default: `config.default_expires_in`
|
|
31
|
+
# and `op.expires_in` are both nil until a host says otherwise.
|
|
32
|
+
def expired?
|
|
33
|
+
request.expires_at.present? && request.expires_at <= Time.current
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Guards
|
|
5
|
+
# May this actor reject this request (§7.1, §7.2)?
|
|
6
|
+
#
|
|
7
|
+
# An eligible approver of the current open stage, or the requester - who may always stop their
|
|
8
|
+
# own request.
|
|
9
|
+
#
|
|
10
|
+
# It says nothing about the reason. The reason is mandatory, but a Reject button is what *opens*
|
|
11
|
+
# the form that collects it, so a guard refusing without one could never let the button appear.
|
|
12
|
+
# `Commands::Reject` enforces it (Q25).
|
|
13
|
+
class Reject < Base
|
|
14
|
+
refuses_with NotRejectable
|
|
15
|
+
|
|
16
|
+
def refusal
|
|
17
|
+
return :not_pending unless request.pending?
|
|
18
|
+
return :stage_not_current if !permitted_here? && eligible_on_another_stage?
|
|
19
|
+
return :already_decided if already_decided?
|
|
20
|
+
return :not_permitted unless permitted_here?
|
|
21
|
+
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def permitted_here?
|
|
28
|
+
requester? || eligible_quorums.any?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Rejection is the one decision the requester may take on their own request: stopping
|
|
32
|
+
# something you asked for needs no four-eyes (§7.2).
|
|
33
|
+
def requester?
|
|
34
|
+
same_person?(request.requester, actor)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Guards
|
|
5
|
+
# May this actor take their own decision back (§7.1, §7.2)?
|
|
6
|
+
#
|
|
7
|
+
# Only the actor who gave it, and only while the stage they gave it on is still open. Cooldown
|
|
8
|
+
# is M9b, so "open" means `pending` here.
|
|
9
|
+
class Unapprove < Base
|
|
10
|
+
refuses_with NotUnapprovable
|
|
11
|
+
|
|
12
|
+
def refusal
|
|
13
|
+
return :not_pending unless request.pending?
|
|
14
|
+
return :not_the_approver if decision.nil?
|
|
15
|
+
return :stage_not_open unless decision.stage.pending?
|
|
16
|
+
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The row this actor is retracting - an approval, or a rejection recorded without
|
|
21
|
+
# short-circuiting (§7.1). The command needs it, so it is resolved once, here.
|
|
22
|
+
#
|
|
23
|
+
# Matched on (type, id) alone. config.actor_identity says who *counts* as one person when
|
|
24
|
+
# tallying approvers (§9.4); retracting is about which row this actor wrote, and one actor
|
|
25
|
+
# undoing another's row would make the trail say something untrue.
|
|
26
|
+
def decision
|
|
27
|
+
return @decision if defined?(@decision)
|
|
28
|
+
|
|
29
|
+
@decision = mine.find_by(change_request_stage_id: stage&.id) || mine.first
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def mine
|
|
35
|
+
request.approvals
|
|
36
|
+
.where(approver_type: actor_ref[:type], approver_id: actor_ref[:id])
|
|
37
|
+
.order(decided_at: :desc)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -34,7 +34,9 @@ module ChangeRequests
|
|
|
34
34
|
define_method(:"#{value}?") { outcome == value }
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
def finished?
|
|
37
|
+
def finished?
|
|
38
|
+
outcome.present?
|
|
39
|
+
end
|
|
38
40
|
|
|
39
41
|
# What the retry ceiling counts against `max_attempts` (§8).
|
|
40
42
|
def self.next_number_for(change_request)
|