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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -0
  3. data/docs/adr/0001-headless-domain-core.md +40 -0
  4. data/docs/adr/0002-mountable-engine-with-isolated-namespace.md +38 -0
  5. data/docs/adr/0003-actor-references-as-triples.md +43 -0
  6. data/docs/adr/0004-uuid-primary-keys.md +35 -0
  7. data/docs/adr/0005-string-states-with-check-constraints.md +43 -0
  8. data/docs/adr/0006-creation-time-immutability.md +39 -0
  9. data/docs/adr/0007-append-only-audit-trail.md +42 -0
  10. data/docs/adr/0008-staged-multi-quorum-schema.md +43 -0
  11. data/docs/adr/0009-host-owned-schema.md +43 -0
  12. data/docs/adr/0010-operations-must-be-declared.md +57 -0
  13. data/docs/adr/0011-system-sentinel-actor.md +36 -0
  14. data/docs/adr/0012-declared-error-taxonomy.md +40 -0
  15. data/docs/adr/0013-json-runtime-pin.md +37 -0
  16. data/docs/adr/0014-executable-architecture-rules.md +45 -0
  17. data/docs/adr/README.md +25 -0
  18. data/lib/change_requests/authorization/callable.rb +30 -0
  19. data/lib/change_requests/authorization/permissions.rb +66 -1
  20. data/lib/change_requests/commands/approve.rb +69 -0
  21. data/lib/change_requests/commands/base.rb +113 -0
  22. data/lib/change_requests/commands/cancel.rb +43 -0
  23. data/lib/change_requests/commands/comment.rb +40 -0
  24. data/lib/change_requests/commands/create.rb +169 -0
  25. data/lib/change_requests/commands/expire.rb +27 -0
  26. data/lib/change_requests/commands/reject.rb +77 -0
  27. data/lib/change_requests/commands/unapprove.rb +49 -0
  28. data/lib/change_requests/configuration.rb +22 -1
  29. data/lib/change_requests/errors.rb +45 -26
  30. data/lib/change_requests/guards/approve.rb +31 -0
  31. data/lib/change_requests/guards/base.rb +201 -0
  32. data/lib/change_requests/guards/cancel.rb +32 -0
  33. data/lib/change_requests/guards/comment.rb +32 -0
  34. data/lib/change_requests/guards/execute.rb +48 -0
  35. data/lib/change_requests/guards/expire.rb +37 -0
  36. data/lib/change_requests/guards/reject.rb +38 -0
  37. data/lib/change_requests/guards/unapprove.rb +41 -0
  38. data/lib/change_requests/models/approval.rb +1 -1
  39. data/lib/change_requests/models/attempt.rb +3 -1
  40. data/lib/change_requests/models/concerns/actor_columns.rb +12 -6
  41. data/lib/change_requests/models/event.rb +3 -1
  42. data/lib/change_requests/models/quorum.rb +7 -2
  43. data/lib/change_requests/models/request.rb +11 -2
  44. data/lib/change_requests/models/stage.rb +10 -3
  45. data/lib/change_requests/operation.rb +6 -2
  46. data/lib/change_requests/operations.rb +14 -4
  47. data/lib/change_requests/translation.rb +3 -1
  48. data/lib/change_requests/version.rb +1 -1
  49. data/lib/change_requests/workflow.rb +3 -1
  50. data/lib/change_requests.rb +9 -3
  51. data/lib/generators/change_requests/install/templates/migration.rb.tt +5 -0
  52. 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
- attr_accessor :authorization, :default_permission_match
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
- # Raised by config.validate! and operations.verify!. Message lists every problem found at once.
26
- class ConfigurationError < Error; end
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
- # `reason` is the contract - controllers branch on it, views render it as a disabled button's
48
- # tooltip. The message is for humans.
49
- class TransitionError < Error
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 = "#{I18N_SCOPE}.#{reason || self.class.error_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
- # NotApprovable => "not_approvable". Used when a caller raised without a reason.
66
- def self.error_key
67
- name.split("::").last.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
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
@@ -26,7 +26,7 @@ module ChangeRequests
26
26
  inverse_of: :approval
27
27
  has_many :quorums, through: :approval_quorums, source: :quorum
28
28
 
29
- actor_reference :approver
29
+ actor_reference :approver, identity: true
30
30
 
31
31
  validates :decided_at, presence: true
32
32
 
@@ -34,7 +34,9 @@ module ChangeRequests
34
34
  define_method(:"#{value}?") { outcome == value }
35
35
  end
36
36
 
37
- def finished? = outcome.present?
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)