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
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Authorization
5
+ # Wraps the host-supplied policy of §9.2:
6
+ #
7
+ # config.authorization = ->(actor:, request:, stage:, action:) {
8
+ # Pundit.policy!(actor, request).public_send("#{action}?")
9
+ # }
10
+ #
11
+ # `Configuration#authorization=` wraps a bare callable in this, so a host writes the lambda and
12
+ # the gem still has one object answering `allows?`.
13
+ #
14
+ # The quorum is the gem's unit of eligibility; a host policy is written against the request, so
15
+ # the request and the stage are resolved from the quorum rather than asked for again.
16
+ class Callable
17
+ attr_reader :policy
18
+
19
+ def initialize(policy)
20
+ @policy = policy
21
+ end
22
+
23
+ def allows?(actor:, quorum:, action: :approve)
24
+ stage = quorum.stage
25
+
26
+ !!policy.call(actor: actor, request: stage.change_request, stage: stage, action: action)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -2,8 +2,73 @@
2
2
 
3
3
  module ChangeRequests
4
4
  module Authorization
5
+ # The default policy: does this actor satisfy this quorum's eligibility rows (§5.3, §9.2)?
6
+ #
7
+ # Written once. M9c re-expresses the identical predicate as the SQL of
8
+ # `Request.awaiting_approval_from`, so "the button is enabled" and "it appears in my inbox"
9
+ # cannot drift apart.
5
10
  class Permissions
6
- # M1b-2 adds allows?(actor:, quorum:). Exists now so config.authorization has its default.
11
+ # `action` is ignored here - eligibility is the same question whichever command asks it. It
12
+ # exists because Authorization::Callable hands it on to the host's own policy.
13
+ def allows?(actor:, quorum:, action: :approve) # rubocop:disable Lint/UnusedMethodArgument
14
+ type = registered_type(actor)
15
+
16
+ return false unless type.may_approve
17
+
18
+ named_approver?(actor, quorum) || satisfies_permission_rows?(actor, type, quorum)
19
+ end
20
+
21
+ private
22
+
23
+ # Raises UnknownActorType for a class that is not registered: the registry is the allowlist,
24
+ # and an unregistered class cannot enter the system through any path (§9.1).
25
+ def registered_type(actor)
26
+ name = actor.class.name
27
+ type = ChangeRequests.config.actor_types[name]
28
+
29
+ return type if type
30
+
31
+ fail UnknownActorType,
32
+ "#{name} is not a registered actor type. " \
33
+ "Register it with `config.actor_type \"#{name}\"`."
34
+ end
35
+
36
+ def named_approver?(actor, quorum)
37
+ quorum.eligible_actors.any? do |row|
38
+ row.actor_type == actor.class.name && row.actor_id == actor.id.to_s
39
+ end
40
+ end
41
+
42
+ def satisfies_permission_rows?(actor, type, quorum)
43
+ rows = quorum.permissions.to_a
44
+
45
+ # "all of nothing" is vacuously true, which would let a quorum that only names its approvers
46
+ # admit everyone. A quorum with no rows grants eligibility to nobody by permission.
47
+ return false if rows.empty?
48
+
49
+ held = permissions_of(actor, type)
50
+ matched = rows.select { |row| row_matches?(row, actor, held) }
51
+
52
+ quorum.all_match? ? matched.size == rows.size : matched.any?
53
+ end
54
+
55
+ # The 2x2 of §5.3. A row constraining neither axis matches nobody: "constrains nothing" is a
56
+ # bug the model and the CHECK both refuse, never a wildcard.
57
+ def row_matches?(row, actor, held)
58
+ return false if row.permission.nil? && row.actor_type.nil?
59
+
60
+ (row.permission.nil? || held.include?(row.permission)) &&
61
+ (row.actor_type.nil? || row.actor_type == actor.class.name)
62
+ end
63
+
64
+ # Through the actor type's own lambda, never through a method on the actor: User and Admin may
65
+ # derive their permissions completely differently and still be compared against one stage
66
+ # definition (§9.2).
67
+ def permissions_of(actor, type)
68
+ return [] if type.permissions.nil?
69
+
70
+ Array(type.permissions.call(actor)).map(&:to_s)
71
+ end
7
72
  end
8
73
  end
9
74
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Commands
5
+ # Records one approver's decision and which quorums it counted toward (§7).
6
+ #
7
+ # Commands::Approve.call(request:, actor: current_user, comment: "Checked with HR")
8
+ class Approve < Base
9
+ # §15.3: two concurrent approvals by the same actor race the unique index, and the loser must
10
+ # hear the same refusal the guard would have given, not a 500.
11
+ on_conflict NotApprovable, reason: :already_decided
12
+
13
+ def self.call(request:, actor:, comment: nil)
14
+ new(request: request, actor: actor, comment: comment).call
15
+ end
16
+
17
+ def perform
18
+ # Built once and asked twice: the reason it refuses and the quorums it links must come from
19
+ # one evaluation, not two (I7).
20
+ guard = Guards::Approve.new(request: request, actor: actor)
21
+ guard.check!
22
+
23
+ approval = record_decision
24
+ link(approval, guard.countable_quorums)
25
+ emit(:approved, body: comment, metadata: metadata_for(approval))
26
+
27
+ # Commands::EvaluateWorkflow is M1b-12. Until it lands nothing advances the stage.
28
+ request
29
+ end
30
+
31
+ private
32
+
33
+ def comment
34
+ options[:comment]
35
+ end
36
+
37
+ def record_decision
38
+ stage.approvals.create!(
39
+ change_request: request,
40
+ approver: actor,
41
+ decision: "approved",
42
+ comment: comment,
43
+ decided_at: Time.current
44
+ )
45
+ end
46
+
47
+ # Written at decision time and never re-derived, so a later role change cannot silently
48
+ # un-approve a request (§5.3).
49
+ def link(approval, quorums)
50
+ quorums.each { |quorum| approval.approval_quorums.create!(quorum: quorum) }
51
+ end
52
+
53
+ # The quorum key is omitted for a stage of one nameless quorum, because "which quorum" is not
54
+ # a meaningful question there (§5.9).
55
+ def metadata_for(approval)
56
+ metadata = { stage: stage.name }
57
+ names = approval.quorums.map(&:name).compact
58
+
59
+ metadata[:quorums] = names if names.any?
60
+
61
+ metadata
62
+ end
63
+
64
+ def stage
65
+ @stage ||= request.current_stage
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Commands
5
+ # Guard, mutate, emit - inside one lock (§7).
6
+ #
7
+ # class Approve < Base
8
+ # on_conflict NotApprovable, reason: :already_decided
9
+ #
10
+ # def perform
11
+ # Guards::Approve.new(request:, actor:).check!
12
+ # ...
13
+ # end
14
+ # end
15
+ #
16
+ # Commands raise rather than returning a result object (§19.3), and never accept permissions
17
+ # from the caller: they take an actor, and the gem resolves that actor's permissions through
18
+ # their registered type (§7, §9).
19
+ class Base
20
+ # Which TransitionError a lost race becomes. Only a command that actually competes for a
21
+ # unique index declares one.
22
+ class_attribute :conflict_mapping, instance_accessor: false
23
+
24
+ attr_reader :request, :actor, :options
25
+
26
+ class << self
27
+ def call(request:, actor: nil, **)
28
+ new(request: request, actor: actor, **).call
29
+ end
30
+
31
+ def on_conflict(error_class, reason:)
32
+ self.conflict_mapping = { error_class: error_class, reason: reason }
33
+ end
34
+ end
35
+
36
+ def initialize(request:, actor: nil, **options)
37
+ @request = request
38
+ @actor = actor
39
+ @options = options
40
+ end
41
+
42
+ # SELECT … FOR UPDATE, which reloads: a command reads the row it locked, never the object it
43
+ # was handed. Rails refuses to lock a record carrying unsaved changes, so a caller cannot lose
44
+ # an assignment it thought it was making.
45
+ def call
46
+ around_perform { perform }
47
+ rescue ActiveRecord::RecordNotUnique => e
48
+ refuse_conflict(e)
49
+ rescue ActiveRecord::StaleObjectError
50
+ raise StaleRequest, "Change request #{request.id} changed while this command was running."
51
+ end
52
+
53
+ def perform
54
+ fail NotImplementedError, "#{self.class.name} must implement #perform"
55
+ end
56
+
57
+ # Create overrides this: it has no row to lock until it has written one.
58
+ def around_perform(&)
59
+ request.with_lock(&)
60
+ end
61
+
62
+ # The single write path for events (§5.5), so the stamped columns are populated in one place.
63
+ # M10 hangs config.on_event and ActiveSupport::Notifications here.
64
+ def emit(kind, body: nil, metadata: {})
65
+ request.events.create!(
66
+ **event_actor,
67
+ kind: kind.to_s,
68
+ operation_version: event_operation_version,
69
+ body: body,
70
+ metadata: metadata,
71
+ occurred_at: Time.current
72
+ )
73
+ end
74
+
75
+ def config
76
+ ChangeRequests.config
77
+ end
78
+
79
+ # Resolved live, never from the columns on the row: those are audit data (§6.12).
80
+ def operation
81
+ ChangeRequests.operations[request.operation_key]
82
+ end
83
+
84
+ private
85
+
86
+ def event_actor
87
+ return Event::SYSTEM_ATTRIBUTES if actor.nil?
88
+
89
+ attributes = ChangeRequests.actor_attributes(actor)
90
+
91
+ { actor_type: attributes[:type], actor_id: attributes[:id], actor_label: attributes[:label] }
92
+ end
93
+
94
+ # The version in force when this transition happened, which is deliberately not the request's
95
+ # creation-time value. They diverge whenever a declaration changes during a request's life.
96
+ # The fallback covers the only events ever written without a live declaration - Comment, and
97
+ # the operation_undeclared cancellation reporting that very disappearance (§5.5, §5.11).
98
+ def event_operation_version
99
+ operation&.version || request.operation_version
100
+ end
101
+
102
+ # Never a 500 for a race the gem can lose (§15.3) - but an undeclared conflict is a bug to
103
+ # see, not a refusal to dress it up as.
104
+ def refuse_conflict(error)
105
+ mapping = self.class.conflict_mapping
106
+
107
+ fail error if mapping.nil?
108
+
109
+ fail mapping[:error_class].new(request: request, reason: mapping[:reason])
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Commands
5
+ # Calls the whole request off (§7.2).
6
+ #
7
+ # Commands::Cancel.call(request:, actor: current_user, reason: "No longer needed")
8
+ #
9
+ # `canceled` is final. Unlike a rejection there is no stage-level counterpart and no cooldown:
10
+ # cancelling is not a decision on a step, it is the end of the request.
11
+ class Cancel < Base
12
+ def self.call(request:, actor:, reason:)
13
+ new(request: request, actor: actor, reason: reason).call
14
+ end
15
+
16
+ def perform
17
+ Guards::Cancel.new(request: request, actor: actor).check!
18
+ refuse_without_reason
19
+
20
+ # Emitted before the status changes, so the trail records the request as it was cancelled
21
+ # rather than as it ended up.
22
+ emit(:canceled, body: reason, metadata: { status: request.status })
23
+ request.update!(status: "canceled")
24
+
25
+ request
26
+ end
27
+
28
+ private
29
+
30
+ def reason
31
+ options[:reason]
32
+ end
33
+
34
+ # Authorization first, as in Reject: someone who may not cancel at all should not be told
35
+ # they merely forgot a sentence.
36
+ def refuse_without_reason
37
+ return if reason.present?
38
+
39
+ fail NotCancelable.new(request: request, reason: :reason_required)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Commands
5
+ # Leaves a note on a request, whatever state it is in (§7.2, §5.5).
6
+ #
7
+ # Commands::Comment.call(request:, actor: current_user, body: "Waiting on legal")
8
+ #
9
+ # Writes an event and nothing else - no request column changes, so the terminal-state guard is
10
+ # never in its way and a `successful` or `canceled` request can still be annotated.
11
+ class Comment < Base
12
+ def self.call(request:, actor:, body:)
13
+ new(request: request, actor: actor, body: body).call
14
+ end
15
+
16
+ def perform
17
+ Guards::Comment.new(request: request, actor: actor).check!
18
+ refuse_without_body
19
+
20
+ # When no live declaration exists, emit stamps the request's creation-time
21
+ # operation_version - the only version there is to record (§5.5, §5.11).
22
+ emit(:commented, body: body)
23
+ end
24
+
25
+ private
26
+
27
+ def body
28
+ options[:body]
29
+ end
30
+
31
+ # An empty note is noise in a trail that can never be cleaned up. Authorization first, as in
32
+ # Reject and Cancel.
33
+ def refuse_without_body
34
+ return if body.present?
35
+
36
+ fail NotAuthorized.new(request: request, reason: :body_required)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Commands
5
+ # Records the intent instead of running it, and freezes the approval policy onto the row (§7.2).
6
+ #
7
+ # Commands::Create.call(
8
+ # operation_key: "members.update_roles",
9
+ # payload: { member_id: 42, roles: %w(editor) },
10
+ # requester: current_admin,
11
+ # tenant: current_organization # optional
12
+ # )
13
+ #
14
+ # M2 wraps this as `ChangeRequests.request!`.
15
+ #
16
+ # The call is identical however elaborate the workflow is: thresholds, permissions and quorum
17
+ # structure come from the declaration, never from the caller (§6.12 point 3).
18
+ class Create < Base
19
+ attr_reader :operation_key, :requester, :payload, :tenant
20
+
21
+ def self.call(operation_key:, requester:, payload: {}, tenant: nil)
22
+ new(operation_key: operation_key, requester: requester, payload: payload, tenant: tenant).call
23
+ end
24
+
25
+ def initialize(operation_key:, requester:, payload: {}, tenant: nil)
26
+ super(request: nil, actor: requester)
27
+
28
+ @operation_key = operation_key.to_s
29
+ @requester = requester
30
+ @payload = payload
31
+ @tenant = tenant
32
+ end
33
+
34
+ # Base's helper resolves the operation from the request, which does not exist yet.
35
+ def operation
36
+ ChangeRequests.operations[operation_key]
37
+ end
38
+
39
+ def perform
40
+ declaration = usable_operation
41
+ refuse_unless_may_request
42
+
43
+ @request = write_request(declaration)
44
+ materialise(declaration.workflow)
45
+ emit(:requested)
46
+
47
+ request
48
+ end
49
+
50
+ private
51
+
52
+ # The whole graph, or none of it.
53
+ def around_perform(&)
54
+ Record.transaction(&)
55
+ end
56
+
57
+ def usable_operation
58
+ declaration = operation
59
+
60
+ fail UnknownOperation, "No operation is declared for #{operation_key.inspect} (§5.11)." if declaration.nil?
61
+
62
+ refuse_incomplete(declaration)
63
+
64
+ declaration
65
+ end
66
+
67
+ # Both would otherwise surface as a NOT NULL violation or as a request that can never leave
68
+ # `pending`. M2's `verify!` catches them at boot; this is the backstop. Reported together,
69
+ # like Configuration#validate!: one call should fix one round of mistakes.
70
+ def refuse_incomplete(declaration)
71
+ problems = []
72
+
73
+ if declaration.service.blank?
74
+ problems << "it declares no service, so nothing could ever execute it - set `op.service`"
75
+ end
76
+
77
+ if declaration.workflow.empty?
78
+ problems << "it declares no approvals, so a request could never be approved - " \
79
+ "declare `op.approvals`"
80
+ end
81
+
82
+ return if problems.empty?
83
+
84
+ fail ConfigurationError,
85
+ "Operation #{operation_key.inspect} cannot be requested (§6.4):\n- #{problems.join("\n- ")}"
86
+ end
87
+
88
+ # Which actions a given person may trigger is the host's own authorization question, answered
89
+ # before this call. The registry answers only which *classes* may raise a request (§19.4).
90
+ def refuse_unless_may_request
91
+ type = ChangeRequests.config.actor_types[requester.class.name]
92
+
93
+ # Unregistered classes are the allowlist's business, and actor_attributes raises for them.
94
+ return if type.nil? || type.may_request
95
+
96
+ fail NotAuthorized,
97
+ "#{requester.class.name} may not raise change requests. " \
98
+ "Set `t.may_request = true` on its registration (§9.1)."
99
+ end
100
+
101
+ def write_request(declaration)
102
+ Request.create!(
103
+ operation_key: declaration.key,
104
+ operation_version: declaration.version,
105
+ service: declaration.service,
106
+ method_name: declaration.method_name.to_s,
107
+ payload: validated_payload,
108
+ payload_labels: labels,
109
+ requester: requester,
110
+ tenant: tenant,
111
+ max_attempts: declaration.max_attempts,
112
+ expires_at: expires_at(declaration)
113
+ )
114
+ end
115
+
116
+ # The gem validates only that it is a JSON object; matching it to the target's signature is
117
+ # the host's responsibility (§6.12).
118
+ def validated_payload
119
+ return payload if payload.is_a?(Hash)
120
+
121
+ fail InvalidPayload,
122
+ "payload must be a JSON object, got #{payload.class}. It is dispatched as " \
123
+ "`**payload.symbolize_keys`, so its keys become the target's keyword arguments (§6.12)."
124
+ end
125
+
126
+ # Snapshotted so a request stays readable after the records the payload refers to are gone.
127
+ def labels
128
+ return {} if operation.payload_labels.nil?
129
+
130
+ operation.payload_labels.call(validated_payload).to_h.transform_keys(&:to_s)
131
+ end
132
+
133
+ def expires_at(declaration)
134
+ declaration.expires_in && (Time.current + declaration.expires_in)
135
+ end
136
+
137
+ # The frozen snapshot: editing the operation afterwards never reaches this request, and never
138
+ # leaves it wrongly approved or wrongly pending (§6.12 point 4).
139
+ def materialise(workflow)
140
+ workflow.stages.each do |described_stage|
141
+ stage = request.stages.create!(
142
+ position: described_stage.position,
143
+ name: described_stage.name.to_s,
144
+ satisfied_by: described_stage.satisfied_by.to_s
145
+ )
146
+
147
+ described_stage.quorums.each { |described| materialise_quorum(stage, described) }
148
+ end
149
+ end
150
+
151
+ def materialise_quorum(stage, described)
152
+ quorum = stage.quorums.create!(
153
+ position: described.position,
154
+ name: described.name&.to_s,
155
+ threshold: described.threshold,
156
+ permission_match: described.permission_match.to_s
157
+ )
158
+
159
+ described.permissions.each do |row|
160
+ quorum.permissions.create!(permission: row.permission, actor_type: row.actor_type)
161
+ end
162
+
163
+ # The declaration kept the actor objects as the host wrote them; this is where they resolve
164
+ # to (type, id), and where an unregistered class is refused (§9.1).
165
+ described.eligible_actors.each { |actor| quorum.eligible_actors.create!(actor: actor) }
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Commands
5
+ # The clock closing a request nobody acted on (§7.2, §8).
6
+ #
7
+ # Commands::Expire.call(request:)
8
+ #
9
+ # No actor: `emit` stamps `SYSTEM_ACTOR` rather than a NULL, so "who did this" is answerable for
10
+ # every row and no presenter branches on nil (§5.5, §19.15).
11
+ #
12
+ # This is the transition only. `Maintenance.expire_stale!`, which sweeps
13
+ # `Request.expired_candidates` on a schedule, is M3b.
14
+ class Expire < Base
15
+ def perform
16
+ Guards::Expire.new(request: request, actor: actor).check!
17
+
18
+ # Emitted before the status changes, so the trail records what expired rather than what it
19
+ # became - which is `expired` for every one of these rows.
20
+ emit(:expired, metadata: { status: request.status, expires_at: request.expires_at })
21
+ request.update!(status: "expired")
22
+
23
+ request
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Commands
5
+ # Rejection is a stop, not a count (§7.1).
6
+ #
7
+ # Commands::Reject.call(request:, actor: current_user, reason: "Wrong member")
8
+ #
9
+ # One rejection from any eligible approver, or from the requester, rejects the whole request.
10
+ # Rejection thresholds are deliberately not modelled.
11
+ #
12
+ # `config.only_record_rejections = true` records the decision and the event without
13
+ # short-circuiting: the workflow continues, and the rejector has spent their decision on that
14
+ # stage - the unique index is what guarantees they cannot later approve it.
15
+ class Reject < Base
16
+ on_conflict NotRejectable, reason: :already_decided
17
+
18
+ def self.call(request:, actor:, reason:)
19
+ new(request: request, actor: actor, reason: reason).call
20
+ end
21
+
22
+ def perform
23
+ Guards::Reject.new(request: request, actor: actor).check!
24
+ refuse_without_reason
25
+
26
+ record_decision
27
+ emit(:rejected, body: reason, metadata: metadata)
28
+
29
+ stop unless config.only_record_rejections
30
+
31
+ # Commands::EvaluateWorkflow is M1b-12, and only the recorded-only branch would call it.
32
+ request
33
+ end
34
+
35
+ private
36
+
37
+ def reason
38
+ options[:reason]
39
+ end
40
+
41
+ # Authorization first: someone who may not reject at all should not be told they merely
42
+ # forgot a sentence.
43
+ def refuse_without_reason
44
+ return if reason.present?
45
+
46
+ fail NotRejectable.new(request: request, reason: :reason_required)
47
+ end
48
+
49
+ # Written in both branches, so change_request_approvals stays the complete record of who
50
+ # decided what on each stage and the unique index behaves identically either way (Q26).
51
+ def record_decision
52
+ stage.approvals.create!(
53
+ change_request: request,
54
+ approver: actor,
55
+ decision: "rejected",
56
+ comment: reason,
57
+ decided_at: Time.current
58
+ )
59
+ end
60
+
61
+ # Whether the workflow continued is the fact an audit asks about first, and it depends on a
62
+ # config flag that may since have been changed.
63
+ def metadata
64
+ { stage: stage.name, recorded_only: config.only_record_rejections }
65
+ end
66
+
67
+ def stop
68
+ stage.update!(status: "rejected")
69
+ request.update!(status: "rejected")
70
+ end
71
+
72
+ def stage
73
+ @stage ||= request.current_stage
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Commands
5
+ # Takes one actor's decision back (§7.1).
6
+ #
7
+ # Commands::Unapprove.call(request:, actor: current_user)
8
+ #
9
+ # The row goes; the trail does not. `Event` is append-only, so the `approved` event stays beside
10
+ # the `unapproved` one and the timeline records both decisions (§5.5).
11
+ class Unapprove < Base
12
+ def self.call(request:, actor:)
13
+ new(request: request, actor: actor).call
14
+ end
15
+
16
+ def perform
17
+ guard = Guards::Unapprove.new(request: request, actor: actor)
18
+ guard.check!
19
+
20
+ decision = guard.decision
21
+ # Read before the row goes: the links are gone a line later.
22
+ metadata = metadata_for(decision)
23
+
24
+ # Deleting the approval is what removes its quorum links: ApprovalQuorum is Immutable, so
25
+ # `decision.quorums.destroy_all` - the obvious code - raises ReadOnlyRecord. The
26
+ # association carries no `dependent:`, and the database cascades (Q15).
27
+ decision.destroy!
28
+
29
+ emit(:unapproved, metadata: metadata)
30
+
31
+ # Commands::EvaluateWorkflow is M1b-12. Until it lands nothing re-counts the stage.
32
+ request
33
+ end
34
+
35
+ private
36
+
37
+ # Names what was retracted and where. Without it, a request that gained and lost the same
38
+ # approval twice would leave two indistinguishable pairs in the timeline (§5.9).
39
+ def metadata_for(decision)
40
+ metadata = { stage: decision.stage.name, decision: decision.decision }
41
+ names = decision.quorums.map(&:name).compact
42
+
43
+ metadata[:quorums] = names if names.any?
44
+
45
+ metadata
46
+ end
47
+ end
48
+ end
49
+ end