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.
@@ -4,8 +4,8 @@ module ChangeRequests
4
4
  module Guards
5
5
  # May this actor execute this request (§7.2, §8)?
6
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.
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 - the `op.approvals` shorthand - because "which
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
- # M2 adds `op.workflow`, `op.cooldown`, `op.override` and `Operations#verify!`.
8
+ # `op.cooldown` arrives with M9b.
9
9
  class Operation
10
- # change_request_stages.name is NOT NULL, and the shorthand declares no stage of its own.
11
- DEFAULT_STAGE_NAME = "approval"
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
- @workflow = Workflow.new
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
- # The §6.4 shorthand: one stage, one quorum, no ceremony. Replaces any previous description -
52
- # it is "the approval rule for this operation", not one of several.
53
- def approvals(permissions: nil, actor_type: nil, eligible_actors: nil, match: nil, required: 1)
54
- quorum = build_quorum(permissions: permissions, actor_type: actor_type,
55
- eligible_actors: eligible_actors, match: match, required: required)
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
- @workflow = Workflow.new(
58
- [Workflow::Stage.new(name: DEFAULT_STAGE_NAME, position: 1, satisfied_by: :any_quorum,
59
- quorums: [quorum])]
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].compact
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
- def build_quorum(permissions:, actor_type:, eligible_actors:, match:, required:)
86
- rows = permission_rows(permissions, actor_type)
87
- actors = list(eligible_actors)
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
- Workflow::Quorum.new(name: nil, position: 1, threshold: required, permission_match: match,
94
- permissions: rows, eligible_actors: actors)
111
+ Array.try_convert(value) || [value]
95
112
  end
96
113
 
97
- def permission_rows(permissions, actor_type)
98
- entries = list(permissions)
114
+ def service_problem
115
+ return if service.present?
99
116
 
100
- return [Workflow::Permission.new(permission: nil, actor_type: actor_type.to_s)] \
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
- def permission_row(entry, actor_type)
107
- return Workflow::Permission.new(permission: entry.to_s, actor_type: actor_type&.to_s) \
108
- unless entry.is_a?(Hash)
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
- Workflow::Permission.new(permission: entry[:permission]&.to_s,
111
- actor_type: (entry[:actor_type] || actor_type)&.to_s)
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
- # Array() would splat a Struct or a Hash into its members. Only a real array is one.
115
- def list(value)
116
- return [] if value.nil?
129
+ def method_name_declared?
130
+ method_name.to_s.strip.present?
131
+ end
117
132
 
118
- Array.try_convert(value) || [value]
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
- def refuse_empty_eligibility
122
- fail ConfigurationError,
123
- "ChangeRequests operation #{key.inspect}: op.approvals needs `permissions:`, " \
124
- "`actor_type:` or `eligible_actors:`. A quorum nobody qualifies for can never be " \
125
- "satisfied, and the request would sit pending until it expired (§5.3)."
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 refuse_threshold(required)
129
- fail ConfigurationError,
130
- "ChangeRequests operation #{key.inspect}: op.approvals required: #{required.inspect}. " \
131
- "It must be an integer of at least 1 - one approval is the minimum, not zero."
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 refuse_match(match)
135
- fail ConfigurationError,
136
- "ChangeRequests operation #{key.inspect}: op.approvals match: #{match.inspect}. " \
137
- "Expected #{Configuration::PERMISSION_MATCHES.map(&:inspect).join(" or ")} (§5.3)."
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChangeRequests
4
- VERSION = "0.2.5"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -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 - the `op.approvals` shorthand (§5.9).
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 = [])
@@ -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.2.5
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: