change_requests 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7790d14f78ffa0b667712d827db6cc77d527dd9f733582b95b0a43ca4372d156
4
- data.tar.gz: 21b70a37fe29a796e855571f82901d1e098328ec5b0d567335c5982b0622ccce
3
+ metadata.gz: 5121ac3d2657395d7e5587ab4cbed6ef9338bb17a15be4c7252ebd09a9a68202
4
+ data.tar.gz: 1255001fa45221fee226d66e986666e2a6b5f8fb417ffea8c89bb9069c8e7c9d
5
5
  SHA512:
6
- metadata.gz: 9fe5fea5c0e07c85cbac8ca55ec947ce59d1a999ec9cfc99d3cd1beeb0615ced473e41ae89f2a2561f7edfeb20c24102a751ffe4eb4b84111df3abff8ca234fd
7
- data.tar.gz: bb3d6b92463ba8c4833400c35b99f1de62fc0304e31203b2d34e8f6e3ef3aef81aba2c4c83886cf296db76f3526d473ca2cea1a01c53ccfce56613d454746a94
6
+ metadata.gz: 0627a54b60fe7cf7d2ad407f2f52227b28605b36a4c4b97bdb833a532155962d9d9dcde116feeb38d7b6ad13c154809be2b000de39e0d4e689950aa5ee2887b6
7
+ data.tar.gz: cb8d036a00b8f9cca8dab2d0ff00c9df9b0737662ea913072b93dce704d239cb39ae4bb46d203c9b93bdce729eceeefbe2da15f705980e235952e58adeec2bc0
data/CLAUDE.md CHANGED
@@ -17,3 +17,21 @@ Act as a Senior Technical Lead and Subject Matter Expert.
17
17
  ## Rules
18
18
 
19
19
  Additionally read and follow: ~/.gemini/config/AGENTS.md and AGENTS.md
20
+
21
+ ## Plan and ADRs
22
+
23
+ The Plan will stay in the branch "plan", you must not commit on branch plan or push branch plan.
24
+ You are allowed to edit the Plan files on branch plan.
25
+
26
+ Under docs/adr you will find architectural decision records. They are maintained by a human, you should not update them, unless explicitly asked.
27
+
28
+ ## Working on a new Ticket
29
+
30
+ Ask me, if any implementation detail for this ticket is unclear or undecided.
31
+
32
+ You will create a new branch for every ticket. base it off main, which has usually been updated with the prior work.
33
+
34
+ When you you are done working on a ticket:
35
+ * push your work to a remote branch with the same name as your new local worktree branch
36
+ * create a PR
37
+ * remove your worktree access, but keep the local branch
data/README.md CHANGED
@@ -28,6 +28,31 @@ gem install change_requests
28
28
 
29
29
  TODO: Write usage instructions here
30
30
 
31
+ ## The target contract
32
+
33
+ The gem asks one thing of the code it executes on your behalf. A change-request target is a **public
34
+ singleton method** that accepts **keyword arguments only**, and whose effect is **idempotent**: running it
35
+ twice with the same payload must leave the same result as running it once.
36
+
37
+ ```ruby
38
+ class Members::UpdateRoles
39
+ def self.call(member_id:, roles:, change_request_id: nil)
40
+ Member.find(member_id).update!(roles: roles)
41
+ end
42
+ end
43
+ ```
44
+
45
+ There is no flag to declare otherwise. A failed request keeps its approval and is retried up to
46
+ `op.max_attempts`, so a target that cannot meet the requirement must leave that at `1` — the retry ceiling
47
+ is what bounds a repeated effect, and it is the only bound the gem can actually enforce.
48
+
49
+ A target that declares `change_request_id:` receives it, stable across every attempt, which one calling an
50
+ external API can pass on as that API's own idempotency key.
51
+
52
+ `rake change_requests:verify` checks the half of this that is checkable: that every declared service
53
+ resolves, and that it answers the singleton method dispatch will call. Idempotence it cannot check, and
54
+ does not try.
55
+
31
56
  ## Architecture
32
57
 
33
58
  The decisions behind the gem's shape — and what each one costs — are recorded as ADRs in
@@ -42,14 +42,12 @@ en:
42
42
  quorum_not_met: "This request does not have the approvals it needs."
43
43
  transition_error: "This request will not accept that."
44
44
 
45
- # Stage and quorum names are declaration identifiers, not display text (§5.9). A host adds a key
46
- # per name it declares; anything unlisted falls back to `name.humanize`, so "sign_off" reads as
47
- # "Sign off" with no locale entry at all.
45
+ # Stage and quorum names are declaration identifiers, not display text (§5.9). The host names
46
+ # every stage, so the gem ships no entry here at all: a host adds a key per name it declares,
47
+ # and anything unlisted falls back to `name.humanize` - "sign_off" reads as "Sign off" with no
48
+ # locale entry.
48
49
  #
49
50
  # stages:
50
51
  # sign_off: "Director sign-off"
51
52
  # quorums:
52
53
  # owners: "Owners"
53
- stages:
54
- # The only name the gem itself creates - `op.approvals` builds one stage called this.
55
- approval: "Approval"
@@ -8,6 +8,15 @@ module ChangeRequests
8
8
  # `Request.awaiting_approval_from`, so "the button is enabled" and "it appears in my inbox"
9
9
  # cannot drift apart.
10
10
  class Permissions
11
+ # Through the actor type's own lambda, never through a method on the actor: User and Admin may
12
+ # derive their permissions completely differently and still be compared against one
13
+ # definition (§9.2). Also read by Guards::Execute's override branch, which has no quorum.
14
+ def self.held_by(actor, type)
15
+ return [] if type.nil? || type.permissions.nil?
16
+
17
+ Array(type.permissions.call(actor)).map(&:to_s)
18
+ end
19
+
11
20
  # `action` is ignored here - eligibility is the same question whichever command asks it. It
12
21
  # exists because Authorization::Callable hands it on to the host's own policy.
13
22
  def allows?(actor:, quorum:, action: :approve) # rubocop:disable Lint/UnusedMethodArgument
@@ -61,13 +70,8 @@ module ChangeRequests
61
70
  (row.actor_type.nil? || row.actor_type == actor.class.name)
62
71
  end
63
72
 
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
73
  def permissions_of(actor, type)
68
- return [] if type.permissions.nil?
69
-
70
- Array(type.permissions.call(actor)).map(&:to_s)
74
+ self.class.held_by(actor, type)
71
75
  end
72
76
  end
73
77
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Commands
5
+ # §8's **T1**: the claim. Guard, take the row, write the attempt, emit - and commit, so the
6
+ # claim is visible to every other process *before* the target runs.
7
+ #
8
+ # An **internal** command, like EvaluateWorkflow: hosts call `Commands::Execute`, which reaches
9
+ # it through `Execution::Runner`. It is a command rather than a Runner method because every
10
+ # event in the gem is written by one, through one `emit` (ADR 0016).
11
+ class ClaimExecution < Base
12
+ CLAIMABLE = %w(approved failed).freeze
13
+
14
+ # §8.1: an override claims a request that never reached `approved`.
15
+ OVERRIDE_CLAIMABLE = %w(pending).freeze
16
+
17
+ def self.call(request:, actor:, override: false, reason: nil)
18
+ new(request: request, actor: actor, override: override, reason: reason).call
19
+ end
20
+
21
+ # The attempt, which T3 finishes.
22
+ def perform
23
+ authorize!
24
+ refuse_without_reason
25
+ claim!
26
+ record_override if override?
27
+ attempt = start_attempt
28
+ emit(:execution_started, metadata: { attempt: attempt.number })
29
+
30
+ attempt
31
+ end
32
+
33
+ private
34
+
35
+ def authorize!
36
+ Guards::Execute.new(request: request, actor: actor, override: override?).check!
37
+ end
38
+
39
+ def override?
40
+ options[:override] ? true : false
41
+ end
42
+
43
+ def reason
44
+ options[:reason]
45
+ end
46
+
47
+ # After the guard, following M1b-7's shape (Q25): someone who may not override at all should
48
+ # not be told they merely forgot a sentence.
49
+ def refuse_without_reason
50
+ return unless override?
51
+ return unless operation.override_policy.require_reason?
52
+ return if reason.present?
53
+
54
+ fail OverrideNotPermitted.new(request: request, reason: :reason_required)
55
+ end
56
+
57
+ # §8.1: at claim time, inside T1, carrying the shortfall exactly as it stood. A later
58
+ # approval must not be able to make an override look retrospectively unnecessary.
59
+ def record_override
60
+ request.update!(overridden_at: Time.current)
61
+ emit(:overridden, body: reason, metadata: shortfall)
62
+ end
63
+
64
+ # Scoped to what is still missing rather than to the whole workflow: what an auditor asks is
65
+ # how far short this request was when someone went ahead anyway.
66
+ def shortfall
67
+ stages = request.stages.reject { |stage| stage.satisfied? || stage.closed? }
68
+ quorums = stages.flat_map { |stage| stage.quorums.reject(&:satisfied?) }
69
+
70
+ {
71
+ approvals_present: quorums.sum { |quorum| quorum.approval_quorums.count },
72
+ approvals_required: quorums.sum(&:threshold),
73
+ incomplete_stages: stages.map(&:name),
74
+ # Omitted rather than null for a single-quorum stage, as every other event does (§5.9).
75
+ incomplete_quorums: quorums.filter_map(&:name),
76
+ }
77
+ end
78
+
79
+ # §8's conditional UPDATE. The guard ran under this transaction's own FOR UPDATE, so in the
80
+ # ordinary race the loser is already refused `:executing` and never arrives here. This is the
81
+ # invariant beneath that: zero rows means somebody else holds the claim, whatever route got
82
+ # here - a background re-entry, or a caller reaching the runner without the guard.
83
+ def claim!
84
+ claimed = Request.where(id: request.id, status: claimable)
85
+ .update_all(status: "executing", updated_at: Time.current)
86
+
87
+ refuse_claimed if claimed.zero?
88
+
89
+ request.reload
90
+ end
91
+
92
+ def claimable
93
+ override? ? OVERRIDE_CLAIMABLE : CLAIMABLE
94
+ end
95
+
96
+ # The unique index on (change_request_id, number) is the claim's second lock: two processes
97
+ # cannot both create attempt 3, whatever they believe about the status column (§5.6).
98
+ def start_attempt
99
+ request.attempts.create!(number: Attempt.next_number_for(request),
100
+ executer: actor, started_at: Time.current)
101
+ rescue ActiveRecord::RecordNotUnique
102
+ refuse_claimed
103
+ end
104
+
105
+ def refuse_claimed
106
+ fail ExecutionInProgress,
107
+ "Change request #{request.id} is already being executed. The claim is committed " \
108
+ "before the target runs, so another process holds this attempt (§8)."
109
+ end
110
+ end
111
+ end
112
+ end
@@ -11,7 +11,7 @@ module ChangeRequests
11
11
  # tenant: current_organization # optional
12
12
  # )
13
13
  #
14
- # M2 wraps this as `ChangeRequests.request!`.
14
+ # `ChangeRequests.request!` is the host-facing wrapper (§6.5); this is the command itself.
15
15
  #
16
16
  # The call is identical however elaborate the workflow is: thresholds, permissions and quorum
17
17
  # structure come from the declaration, never from the caller (§6.12 point 3).
@@ -64,20 +64,11 @@ module ChangeRequests
64
64
  declaration
65
65
  end
66
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.
67
+ # The same `Operation#problems` that `verify!` reads at boot and `validate!` reads at
68
+ # declaration, so the three cannot disagree about what a complete declaration is (§7.2 †).
69
+ # This is the backstop: an operation mutated after it was declared still refuses here.
70
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
71
+ problems = declaration.problems
81
72
 
82
73
  return if problems.empty?
83
74
 
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Commands
5
+ # §6.6's sixth command, in the shape of the other five: the request, the acting actor, and a
6
+ # typed error on refusal.
7
+ #
8
+ # Commands::Execute.call(request:, actor: current_user)
9
+ #
10
+ # The work is §8's three transactions, so this is the one command that takes **no lock of its
11
+ # own**: `Execution::Runner` opens one per transaction, and none of them spans the target
12
+ # invocation. `Guards::Execute` runs inside T1, against the row it locked - a guard call out
13
+ # here would read an unlocked row and could disagree with the claim that follows it.
14
+ #
15
+ # `override: true` takes §8.1's branch: a different guard question, a claim from `pending`,
16
+ # `overridden_at`, and an `overridden` event carrying the shortfall. `Commands::Override` is
17
+ # the named entry point a host calls for it (Q7).
18
+ class Execute < Base
19
+ def self.call(request:, actor:, override: false, reason: nil)
20
+ new(request: request, actor: actor, override: override, reason: reason).call
21
+ end
22
+
23
+ def perform
24
+ Execution::Runner.call(request: request, actor: actor,
25
+ override: options[:override], reason: options[:reason])
26
+ end
27
+
28
+ # Base locks around `perform`; §8 forbids holding one across T2. Create overrides this too,
29
+ # for the opposite reason - it has no row to lock until it has written one.
30
+ def around_perform
31
+ yield
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Commands
5
+ # §6.10's break-glass entry point: `Execute` with `override: true`, and a name that says so.
6
+ #
7
+ # Commands::Override.call(request:, actor:, reason: "Provider outage, CFO approved by phone")
8
+ #
9
+ # A thin wrapper on purpose (Q7). §8.1 wants the exception to look like one, and
10
+ # `Execute.call(…, override: true)` buried in a controller does not. It also gives M5's
11
+ # presenter two distinct actions to render - `:execute` and `:execute_override`, the second
12
+ # `tone: :danger` and always confirmed - without branching on a boolean.
13
+ #
14
+ # A subclass rather than a delegation, so the rows and events it writes cannot drift from
15
+ # `Execute(override: true)`: there is nothing here to drift.
16
+ class Override < Execute
17
+ def self.call(request:, actor:, reason: nil)
18
+ new(request: request, actor: actor, override: true, reason: reason).call
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Commands
5
+ # §8's **T3**: what the target did. A separate transaction from T1 and from the invocation, so a
6
+ # target that raises leaves no business change and still leaves a durable record of the failure.
7
+ #
8
+ # Internal, like ClaimExecution. `error:` nil is the success branch.
9
+ class SettleExecution < Base
10
+ # Enough to locate the failure, bounded so a deep stack cannot write a megabyte per attempt
11
+ # into a host's database. The column is text; nothing else truncates it.
12
+ BACKTRACE_FRAMES = 20
13
+
14
+ def self.call(request:, actor:, attempt:, error: nil)
15
+ new(request: request, actor: actor, attempt: attempt, error: error).call
16
+ end
17
+
18
+ def perform
19
+ error.nil? ? record_success : record_failure
20
+
21
+ request
22
+ end
23
+
24
+ private
25
+
26
+ def attempt
27
+ options.fetch(:attempt)
28
+ end
29
+
30
+ def error
31
+ options[:error]
32
+ end
33
+
34
+ def record_success
35
+ request.update!(status: "successful", executed_at: Time.current, executer: actor)
36
+ attempt.update!(outcome: "succeeded", finished_at: Time.current)
37
+ emit(:executed, metadata: { attempt: attempt.number })
38
+ end
39
+
40
+ # `failed` is not final: the request keeps its approval, and `retryable?` against max_attempts
41
+ # is the only thing bounding another go (§8).
42
+ def record_failure
43
+ request.update!(status: "failed")
44
+ attempt.update!(outcome: "failed", finished_at: Time.current, error_class: error.class.name,
45
+ error_message: error.message, backtrace: bounded_backtrace)
46
+ emit(:execution_failed, body: error.message,
47
+ metadata: { attempt: attempt.number, error_class: error.class.name })
48
+ end
49
+
50
+ def bounded_backtrace
51
+ Array(error.backtrace).first(BACKTRACE_FRAMES).join("\n").presence
52
+ end
53
+ end
54
+ end
55
+ end
@@ -19,5 +19,16 @@ module ChangeRequests
19
19
  config.after_initialize do
20
20
  ChangeRequests.config.validate!
21
21
  end
22
+
23
+ # §6.12 point 6, in development and test only: an operation broken by an edit fails the next
24
+ # request rather than the next deploy. Production runs `rake change_requests:verify` instead,
25
+ # because verify! constantizes every declared service and a booted app should not pay for that
26
+ # on every request cycle.
27
+ #
28
+ # Registered here rather than in the domain core, and it re-reads the registry on every run -
29
+ # a reload redefines the classes verify! resolved last time, so nothing may be held across one.
30
+ config.to_prepare do
31
+ ChangeRequests.operations.verify! if Rails.env.local?
32
+ end
22
33
  end
23
34
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Execution
5
+ # §6.12 points 1-2: the allowlist dispatch, and §8's T2 - the one step that holds no row lock.
6
+ #
7
+ # Dispatcher.call(operation_key: "members.update_roles", payload:, change_request_id:)
8
+ #
9
+ # It takes a **key**, never a request row. `operation_key -> (service, method_name)` resolves
10
+ # from the live declaration, so the `service` and `method_name` columns stay audit data and a
11
+ # careless endpoint that writes a row cannot choose what runs (§6.12 point 1).
12
+ class Dispatcher
13
+ def self.call(operation_key:, change_request_id:, payload: {})
14
+ new(operation_key: operation_key, change_request_id: change_request_id, payload: payload).call
15
+ end
16
+
17
+ def initialize(operation_key:, change_request_id:, payload: {})
18
+ @operation_key = operation_key.to_s
19
+ @change_request_id = change_request_id
20
+ @payload = payload
21
+ end
22
+
23
+ # Whatever the target returns. §8's T3 records the outcome; this step only runs it.
24
+ def call
25
+ target.public_send(operation.method_name, **arguments)
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :operation_key, :change_request_id, :payload
31
+
32
+ # Before anything is constantized: an undeclared key never reaches a target name at all, and
33
+ # §5.11 words it the same way `Commands::Create` does.
34
+ def operation
35
+ return @operation if @operation
36
+
37
+ declaration = ChangeRequests.operations[operation_key]
38
+
39
+ fail UnknownOperation, "No operation is declared for #{operation_key.inspect} (§5.11)." if declaration.nil?
40
+
41
+ @operation = declaration
42
+ end
43
+
44
+ def contract
45
+ @contract ||= TargetContract.new(service: operation.service, method_name: operation.method_name)
46
+ end
47
+
48
+ def target
49
+ @target ||= TargetContract.target!(service: operation.service, method_name: operation.method_name)
50
+ end
51
+
52
+ # Top-level only, because that is what round-trips through jsonb: a nested hash keeps its
53
+ # string keys, and a target taking one should expect them (§6.12).
54
+ def arguments
55
+ keywords = validated_payload.symbolize_keys
56
+
57
+ return keywords unless contract.accepts?(:change_request_id)
58
+
59
+ # Ours wins over a payload key of the same name: it is the identity the gem guarantees is
60
+ # stable across every attempt, which is the whole reason a target would want it (§8).
61
+ keywords.merge(change_request_id: change_request_id)
62
+ end
63
+
64
+ def validated_payload
65
+ return payload if payload.is_a?(Hash)
66
+
67
+ fail InvalidPayload,
68
+ "payload must be a JSON object, got #{payload.class}. It is dispatched as " \
69
+ "`**payload.symbolize_keys`, so its keys become the target's keyword arguments (§6.12)."
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Execution
5
+ # §8's three transactions, which are the whole double-execution fix:
6
+ #
7
+ # T1 claim with_lock, conditional UPDATE, attempt, execution_started, COMMIT
8
+ # T2 invoke no lock - may take seconds, may call an external API
9
+ # T3 settle with_lock, the outcome on the request and on the attempt, and its event
10
+ #
11
+ # The claim is committed before the side effect runs, which is what makes this stronger than
12
+ # one lock around all three: nothing in T2 holds the row, so the request is visibly `executing`
13
+ # to every other process and to the UI while the target works.
14
+ #
15
+ # `Commands::Execute` is the host-facing entry point (M3a-3); this is the machinery.
16
+ class Runner
17
+ def self.call(request:, actor:, override: false, reason: nil)
18
+ new(request: request, actor: actor, override: override, reason: reason).call
19
+ end
20
+
21
+ def initialize(request:, actor:, override: false, reason: nil)
22
+ @request = request
23
+ @actor = actor
24
+ @override = override
25
+ @reason = reason
26
+ end
27
+
28
+ def call
29
+ attempt = Commands::ClaimExecution.call(request: request, actor: actor,
30
+ override: override, reason: reason)
31
+
32
+ begin
33
+ invoke
34
+ rescue StandardError => e
35
+ # Recorded before it is re-raised, and re-raised from inside this rescue so `#cause` is
36
+ # the target's own error. The attempt row carries its class too, so a host that needs to
37
+ # tell a misconfiguration from a flaky call reads `error_class` rather than unwrapping.
38
+ settle(attempt, e)
39
+
40
+ raise TargetFailed, "#{target_description} raised #{e.class}: #{e.message}"
41
+ end
42
+
43
+ settle(attempt, nil)
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :request, :actor, :override, :reason
49
+
50
+ def invoke
51
+ Dispatcher.call(operation_key: request.operation_key, payload: request.payload,
52
+ change_request_id: request.id)
53
+ end
54
+
55
+ # From the declaration, never from the row's columns: those are the creation-time snapshot,
56
+ # and naming them here would report a target that did not run (§6.12 point 1).
57
+ def target_description
58
+ operation = ChangeRequests.operations[request.operation_key]
59
+
60
+ return request.operation_key if operation.nil?
61
+
62
+ "#{operation.service}.#{operation.method_name}"
63
+ end
64
+
65
+ def settle(attempt, error)
66
+ Commands::SettleExecution.call(request: request, actor: actor, attempt: attempt, error: error)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Execution
5
+ # §6.12's target contract, in one place: a **public singleton method** taking **keyword
6
+ # arguments only**. `Operation#target_problems` reads it at boot and `Dispatcher` at dispatch,
7
+ # so `verify!` and execution cannot word the same defect differently.
8
+ #
9
+ # Idempotence is the third half of the contract and is not here, because nothing can check it.
10
+ class TargetContract
11
+ # `:block` is not positional in the sense that matters - a target may take one and ignore it.
12
+ POSITIONAL = %i(req opt rest).freeze
13
+ KEYWORDS = %i(key keyreq).freeze
14
+
15
+ def self.problem(service:, method_name:)
16
+ new(service: service, method_name: method_name).problem
17
+ end
18
+
19
+ # The constant, or a ConfigurationError carrying the same words `problem` would have returned.
20
+ # A target that cannot be dispatched is a declaration error, not a target failure: retrying it
21
+ # could never succeed, and M3a's retry ceiling exists for failures that might (§8).
22
+ def self.target!(service:, method_name:)
23
+ contract = new(service: service, method_name: method_name)
24
+ problem = contract.problem
25
+
26
+ fail ConfigurationError, problem if problem
27
+
28
+ contract.target
29
+ end
30
+
31
+ def initialize(service:, method_name:)
32
+ @service = service
33
+ @method_name = method_name
34
+ end
35
+
36
+ def target
37
+ @target ||= service.to_s.safe_constantize
38
+ end
39
+
40
+ def problem
41
+ return unresolved_service if target.nil?
42
+ return unanswered_method unless target.respond_to?(method_name)
43
+ return positional_arguments if positional_parameters.any?
44
+
45
+ nil
46
+ end
47
+
48
+ # Whether the target would accept this keyword: it declares it, or it forwards everything.
49
+ def accepts?(keyword)
50
+ parameters.any? do |kind, name|
51
+ kind == :keyrest || (KEYWORDS.include?(kind) && name == keyword)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ attr_reader :service, :method_name
58
+
59
+ # A target answering through method_missing has no Method to inspect. Nothing can be proven
60
+ # about its parameters, so nothing is claimed: an empty list refuses nothing.
61
+ def parameters
62
+ @parameters ||= target.method(method_name).parameters
63
+ rescue NameError
64
+ @parameters = []
65
+ end
66
+
67
+ def positional_parameters
68
+ parameters.filter_map { |kind, name| name || kind if POSITIONAL.include?(kind) }
69
+ end
70
+
71
+ def unresolved_service
72
+ "op.service is #{service.inspect}, which does not resolve to a constant. Execution " \
73
+ "dispatches through the declaration, never through the strings on the row (§6.12 point 1)."
74
+ end
75
+
76
+ def unanswered_method
77
+ "#{service} does not answer .#{method_name}. Dispatch calls the public singleton method, so " \
78
+ "an instance method of the same name is not the one it will reach (§6.12)."
79
+ end
80
+
81
+ def positional_arguments
82
+ "#{service}.#{method_name} takes positional arguments " \
83
+ "(#{positional_parameters.join(", ")}). A change-request target accepts keyword arguments " \
84
+ "only - the payload is dispatched as `**payload.symbolize_keys` (§6.12)."
85
+ end
86
+ end
87
+ end
88
+ end
@@ -81,10 +81,16 @@ module ChangeRequests
81
81
  refusal
82
82
  end
83
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
84
+ # Reasons the taxonomy gives a class of their own, raised in place of the guard's declared
85
+ # one. `already_finalized`: a request that is already over is the same refusal whichever
86
+ # command met it, and the model's TerminalStateGuard raises exactly this with exactly this
87
+ # reason (§5.8), so a host rescuing AlreadyFinalized catches both. `override_not_permitted`:
88
+ # §8.1's break-glass refusal is not an ordinary NotExecutable, and a host alerting on
89
+ # attempted overrides rescues it by name.
90
+ REASON_ERRORS = {
91
+ already_finalized: AlreadyFinalized,
92
+ override_not_permitted: OverrideNotPermitted,
93
+ }.freeze
88
94
 
89
95
  def check!
90
96
  return request if allowed?