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 +4 -4
- data/CLAUDE.md +18 -0
- data/README.md +25 -0
- data/config/locales/en.yml +4 -6
- data/lib/change_requests/authorization/permissions.rb +10 -6
- data/lib/change_requests/commands/claim_execution.rb +112 -0
- data/lib/change_requests/commands/create.rb +5 -14
- data/lib/change_requests/commands/execute.rb +35 -0
- data/lib/change_requests/commands/override.rb +22 -0
- data/lib/change_requests/commands/settle_execution.rb +55 -0
- data/lib/change_requests/engine.rb +11 -0
- data/lib/change_requests/execution/dispatcher.rb +73 -0
- data/lib/change_requests/execution/runner.rb +70 -0
- data/lib/change_requests/execution/target_contract.rb +88 -0
- data/lib/change_requests/guards/base.rb +10 -4
- data/lib/change_requests/guards/execute.rb +40 -2
- data/lib/change_requests/models/quorum.rb +1 -1
- data/lib/change_requests/operation/override.rb +22 -0
- data/lib/change_requests/operation.rb +75 -55
- data/lib/change_requests/operations.rb +18 -0
- data/lib/change_requests/version.rb +1 -1
- data/lib/change_requests/workflow/builder.rb +112 -0
- data/lib/change_requests/workflow/quorum.rb +1 -1
- data/lib/change_requests/workflow/stage_builder.rb +108 -0
- data/lib/change_requests/workflow.rb +4 -0
- data/lib/change_requests.rb +10 -0
- data/lib/tasks/change_requests.rake +21 -0
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5121ac3d2657395d7e5587ab4cbed6ef9338bb17a15be4c7252ebd09a9a68202
|
|
4
|
+
data.tar.gz: 1255001fa45221fee226d66e986666e2a6b5f8fb417ffea8c89bb9069c8e7c9d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/config/locales/en.yml
CHANGED
|
@@ -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).
|
|
46
|
-
#
|
|
47
|
-
# "Sign off" with no
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
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
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
|
|
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?
|