change_requests 0.3.0 → 0.4.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/README.md +4 -0
- data/config/locales/en.yml +7 -0
- data/docs/05_execution_and_idempotency.md +129 -0
- data/docs/adr/0015-one-guard-object-per-transition.md +25 -6
- data/docs/adr/0016-commands-are-the-only-writers.md +28 -7
- data/docs/adr/0022-execution-in-three-transactions.md +77 -0
- data/docs/adr/0023-one-declaration-surface.md +68 -0
- data/docs/adr/0024-idempotence-is-required-not-declared.md +57 -0
- data/docs/adr/0025-verification-from-one-set-of-checks.md +62 -0
- data/docs/adr/0026-distinct-intent-distinct-command.md +59 -0
- data/docs/adr/0027-activejob-is-optional.md +55 -0
- data/docs/adr/0028-sweeps-are-rake-tasks.md +67 -0
- data/docs/adr/README.md +7 -0
- data/lib/change_requests/commands/cancel.rb +11 -1
- data/lib/change_requests/commands/cancel_undeclared.rb +46 -0
- data/lib/change_requests/commands/reap.rb +37 -0
- data/lib/change_requests/commands/settle_execution.rb +27 -3
- data/lib/change_requests/configuration.rb +35 -0
- data/lib/change_requests/engine.rb +7 -0
- data/lib/change_requests/execution/job.rb +27 -0
- data/lib/change_requests/execution/runner.rb +31 -2
- data/lib/change_requests/guards/base.rb +2 -0
- data/lib/change_requests/guards/cancel.rb +9 -1
- data/lib/change_requests/guards/reap.rb +59 -0
- data/lib/change_requests/maintenance.rb +48 -0
- data/lib/change_requests/models/request.rb +18 -0
- data/lib/change_requests/version.rb +1 -1
- data/lib/change_requests.rb +37 -1
- data/lib/tasks/change_requests.rake +39 -1
- metadata +14 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 60c060656fc690b5b61123d202e1a3c857836d978cf378ec1275c923863c78c7
|
|
4
|
+
data.tar.gz: 629a3e88dde76ecc8e2d73c63948050d409fe19bc7ccc07704579cbb6b5098cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9b7344ea008f2a2cec9ac577d44c51a2232475066ce280165f6c60b8ba8d8f617a168d062b2b49f3864d89b9992967b1c52193677d5d375f67f640861aee310e
|
|
7
|
+
data.tar.gz: 4502a057ff2fd923566ffaef2ee0149fe39170b11e6254d498138e2959b1eb3992cb2bdfeabc098aa9d205b6822cea5293f25cf41b509a9b63813af4b31e2a1b
|
data/README.md
CHANGED
|
@@ -53,6 +53,10 @@ external API can pass on as that API's own idempotency key.
|
|
|
53
53
|
resolves, and that it answers the singleton method dispatch will call. Idempotence it cannot check, and
|
|
54
54
|
does not try.
|
|
55
55
|
|
|
56
|
+
[docs/05_execution_and_idempotency.md](docs/05_execution_and_idempotency.md) covers the rest: how an
|
|
57
|
+
execution is claimed and settled, inline against background mode, and the two maintenance tasks that
|
|
58
|
+
belong on a crontab.
|
|
59
|
+
|
|
56
60
|
## Architecture
|
|
57
61
|
|
|
58
62
|
The decisions behind the gem's shape — and what each one costs — are recorded as ADRs in
|
data/config/locales/en.yml
CHANGED
|
@@ -37,11 +37,18 @@ en:
|
|
|
37
37
|
not_cancelable: "This request cannot be cancelled."
|
|
38
38
|
not_executable: "This request cannot be executed."
|
|
39
39
|
not_rejectable: "This request cannot be rejected."
|
|
40
|
+
not_executing: "This request is not being executed."
|
|
41
|
+
not_stuck: "This request's execution has not been running long enough to write off."
|
|
40
42
|
not_unapprovable: "This decision cannot be taken back."
|
|
41
43
|
override_not_permitted: "You are not allowed to override the approvals."
|
|
42
44
|
quorum_not_met: "This request does not have the approvals it needs."
|
|
43
45
|
transition_error: "This request will not accept that."
|
|
44
46
|
|
|
47
|
+
# Event bodies the gem writes for itself, where there is no actor to supply one. Translatable
|
|
48
|
+
# rather than a hardcoded sentence, with the same fallback every other lookup has (§5.11).
|
|
49
|
+
events:
|
|
50
|
+
operation_undeclared: "This operation is no longer declared, so the request could never run."
|
|
51
|
+
|
|
45
52
|
# Stage and quorum names are declaration identifiers, not display text (§5.9). The host names
|
|
46
53
|
# every stage, so the gem ships no entry here at all: a host adds a key per name it declares,
|
|
47
54
|
# and anything unlisted falls back to `name.humanize` - "sign_off" reads as "Sign off" with no
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Execution and idempotency
|
|
2
|
+
|
|
3
|
+
A change request defers an action. This is what happens when someone finally executes it, what the
|
|
4
|
+
gem promises about running it twice, and what has to be swept up afterwards.
|
|
5
|
+
|
|
6
|
+
## The target contract
|
|
7
|
+
|
|
8
|
+
A change-request target is a **public singleton method** taking **keyword arguments only**, whose
|
|
9
|
+
effect is **idempotent** — running it twice with the same payload leaves the same result as running
|
|
10
|
+
it once.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
class Members::UpdateRoles
|
|
14
|
+
def self.call(member_id:, roles:, change_request_id: nil)
|
|
15
|
+
Member.find(member_id).update!(roles: roles)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
There is no flag to declare otherwise. A failed request keeps its approval and may be retried up to
|
|
21
|
+
`op.max_attempts`, so a target that cannot meet the requirement leaves that at `1` and gets one
|
|
22
|
+
attempt — the retry ceiling is the only bound the gem can actually enforce.
|
|
23
|
+
|
|
24
|
+
A target declaring `change_request_id:` receives it, **stable across every attempt**. A target
|
|
25
|
+
calling an external API can hand that over as the API's own idempotency key, so a provider that saw
|
|
26
|
+
a timed-out first call recognises the retry instead of charging twice. A per-attempt token would
|
|
27
|
+
defeat exactly that.
|
|
28
|
+
|
|
29
|
+
`rake change_requests:verify` checks the half of this that is checkable: every declared service
|
|
30
|
+
resolves, answers the singleton method dispatch will call, and takes keyword arguments only.
|
|
31
|
+
Idempotence it cannot check, and does not try.
|
|
32
|
+
|
|
33
|
+
## Three transactions
|
|
34
|
+
|
|
35
|
+
Execution must never happen twice, and no row lock may be held while the target runs — an external
|
|
36
|
+
call can take seconds, and a lock held across it blocks every other reader of that request.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
T1 with_lock guard, claim the row, write the attempt, emit execution_started, COMMIT
|
|
40
|
+
T2 no lock invoke the target
|
|
41
|
+
T3 with_lock record the outcome on the request and the attempt, emit its event
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The claim is **committed before the side effect runs**, which is what makes this stronger than one
|
|
45
|
+
lock around all three. Between T1 and T3 the request is visibly `executing` to every other process
|
|
46
|
+
and to the UI, and a second executor is refused.
|
|
47
|
+
|
|
48
|
+
T1's `UPDATE … WHERE status IN ('approved','failed')` is the invariant beneath the guard: zero rows
|
|
49
|
+
means somebody else holds the claim, and nothing is invoked. The unique index on
|
|
50
|
+
`(change_request_id, number)` is its second lock — two processes cannot both write attempt 3.
|
|
51
|
+
|
|
52
|
+
T3's failure branch is its own transaction, so a target that raises leaves **no business change and
|
|
53
|
+
a durable record of the failure**: `error_class`, `error_message` and a bounded backtrace on the
|
|
54
|
+
attempt, and an `execution_failed` event carrying the message.
|
|
55
|
+
|
|
56
|
+
## Inline and background
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
config.execution_mode = :inline # default
|
|
60
|
+
config.execution_mode = :background # T2 and T3 run in ChangeRequests::Execution::Job
|
|
61
|
+
config.job_class = "ChangeRequests::Execution::Job"
|
|
62
|
+
config.job_queue = :default
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
T1 commits synchronously in both modes, so the request shows `executing` the moment the call
|
|
66
|
+
returns and the UI never has to guess. Only the invocation and its outcome move to the job.
|
|
67
|
+
|
|
68
|
+
`ChangeRequests::Execution::Job` is defined **only where ActiveJob is loaded**. The gem has no
|
|
69
|
+
ActiveJob dependency, so a headless process may have `:background` configured and no job at all:
|
|
70
|
+
it boots, `validate!` passes, and `ChangeRequests.background_available?` answers `false`. The
|
|
71
|
+
failure arrives when something tries to enqueue, naming ActiveJob.
|
|
72
|
+
|
|
73
|
+
## Keeping the tables tidy
|
|
74
|
+
|
|
75
|
+
Three sweeps, none of which happens on its own. Each reports how many rows it moved and exits
|
|
76
|
+
non-zero only on error.
|
|
77
|
+
|
|
78
|
+
| Task | Moves | Why it is not automatic |
|
|
79
|
+
|------|-------|--------------------------|
|
|
80
|
+
| `change_requests:expire_stale` | `pending` / `approved` past `expires_at` → `expired` | nothing to decide; put it on cron |
|
|
81
|
+
| `change_requests:reap_stuck_executions` | `executing` whose attempt nobody settled → `failed`, attempt `abandoned` | nothing to decide; put it on cron |
|
|
82
|
+
| `change_requests:cancel_undeclared` | open requests whose operation is no longer declared → `canceled` | **run it by hand** — see below |
|
|
83
|
+
|
|
84
|
+
### The crontab
|
|
85
|
+
|
|
86
|
+
```cron
|
|
87
|
+
5 * * * * cd /app && bin/rails change_requests:expire_stale
|
|
88
|
+
6 * * * * cd /app && bin/rails change_requests:reap_stuck_executions
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Five past rather than on the hour, so they do not land with every other hourly job in the estate,
|
|
92
|
+
and a minute apart so they do not contend with each other.
|
|
93
|
+
|
|
94
|
+
**The interval is a recommendation, not a requirement.** `expires_at` and the reaper's `older_than`
|
|
95
|
+
are the real deadlines; a sweeper running late moves the same rows, just later. Run them every ten
|
|
96
|
+
minutes if your requests are short-lived, or nightly if they are not.
|
|
97
|
+
|
|
98
|
+
The reaper's threshold defaults to one hour and takes an override:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
bin/rails change_requests:reap_stuck_executions OLDER_THAN=600
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Set it comfortably longer than your slowest target. A threshold shorter than a legitimate run will
|
|
105
|
+
write off an execution that is still working — the row goes to `failed` while the target keeps
|
|
106
|
+
going, and nothing recalls it.
|
|
107
|
+
|
|
108
|
+
### Why `cancel_undeclared` is not on that crontab
|
|
109
|
+
|
|
110
|
+
A request whose `operation_key` is no longer declared can never execute, and the gem already treats
|
|
111
|
+
it as finished: every guard but `Comment`, `Cancel` and `Reap` refuses it, and it disappears from
|
|
112
|
+
inboxes and badges immediately.
|
|
113
|
+
|
|
114
|
+
Both of those are **immediate and reversible**. Cancelling is neither — `canceled` is final. And a
|
|
115
|
+
missing declaration is as likely to be a deploy accident, an initializer that did not load or a file
|
|
116
|
+
renamed, as a deliberate removal. A scheduled sweep would turn a bad deploy into a table of
|
|
117
|
+
permanently cancelled requests within the hour.
|
|
118
|
+
|
|
119
|
+
So it runs when an operator has looked and decided:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
bin/rails change_requests:cancel_undeclared
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
If the declaration vanished by mistake, revert the change instead and the requests carry on from
|
|
126
|
+
where they were, with nothing lost.
|
|
127
|
+
|
|
128
|
+
To retire an operation that still has live requests, deprecate rather than delete: keep the
|
|
129
|
+
declaration, stop creating requests against it, and let the outstanding ones drain.
|
|
@@ -24,14 +24,26 @@ and the presenter build the same object.
|
|
|
24
24
|
`Guards::Base::REASONS`, the closed shared vocabulary. Branch order inside `refusal` is part of
|
|
25
25
|
the contract: it decides which of several true refusals the person is shown.
|
|
26
26
|
- `refuses_with` declares the error class, rather than deriving it from the guard's name —
|
|
27
|
-
`Comment` and `
|
|
27
|
+
`Comment`, `Expire` and `Reap` refuse with `NotAuthorized`, the decision guards with their own
|
|
28
28
|
`TransitionError` ([ADR-0012](0012-declared-error-taxonomy.md)).
|
|
29
|
-
-
|
|
30
|
-
whichever guard produced it, so a host
|
|
31
|
-
is the same class and
|
|
29
|
+
- Two reasons override that declaration, through `Guards::Base::REASON_ERRORS`.
|
|
30
|
+
`:already_finalized` always raises `AlreadyFinalized`, whichever guard produced it, so a host
|
|
31
|
+
rescuing "this request is over" catches every command — the same class and reason the model's
|
|
32
|
+
terminal-state guard raises underneath. `:override_not_permitted` always raises
|
|
33
|
+
`OverrideNotPermitted`, so a host alerting on attempted break-glass rescues it by name rather
|
|
34
|
+
than filtering `NotExecutable` by reason.
|
|
32
35
|
- The undeclared-operation refusal lives in `Guards::Base` and runs before `refusal`, so no guard
|
|
33
|
-
repeats it.
|
|
34
|
-
removed declaration is exactly the one someone needs to leave a note on
|
|
36
|
+
repeats it. Three guards opt out with `exempt_from_undeclared_operation!`: `Comment`, because a
|
|
37
|
+
request stranded by a removed declaration is exactly the one someone needs to leave a note on;
|
|
38
|
+
`Cancel`, because it is the one worth clearing away — and to anyone, the requester-or-approver
|
|
39
|
+
rule being dropped along with the refusal; and `Reap`, because a claim that died is dead whatever
|
|
40
|
+
the registry says. The third exists because `Cancel` refuses an `executing` request, so without
|
|
41
|
+
it a request claimed as its declaration vanished could be cleared by nothing at all.
|
|
42
|
+
|
|
43
|
+
**One guard object, built once.** Where a command needs both the decision and what it was decided
|
|
44
|
+
about, it reads them off the same instance rather than recomputing: `Guards::Approve#countable_quorums`
|
|
45
|
+
is the quorums an approval links to, and `Guards::Reap#stuck_attempt` is the row the reaper writes
|
|
46
|
+
off. Recomputing either in the command is how the reason and the write drift apart.
|
|
35
47
|
|
|
36
48
|
**There is no `Guards::Create`.** A guard asks "may this actor do X *to this row*", and at creation
|
|
37
49
|
there is no row. `Commands::Create` runs the three equivalent checks inline. The presenter's half of
|
|
@@ -58,3 +70,10 @@ implementation, not by a guard carrying a second signature.
|
|
|
58
70
|
had already drifted once before that spec existed.
|
|
59
71
|
- A guard resolves the acting actor through the registry, so an unregistered class raises
|
|
60
72
|
`UnknownActorType` rather than producing a refusal. The allowlist is deliberately not a reason.
|
|
73
|
+
- Two guards are system-only — `Expire` and `Reap` — so supplying an actor at all is itself the
|
|
74
|
+
refusal (`:not_system`). Every cross-guard spec has to special-case them, because the actor axis
|
|
75
|
+
the other six share does not apply.
|
|
76
|
+
- `Guards::Execute` answers two questions in one class: the ordinary branch and §8.1's override
|
|
77
|
+
branch, chosen by an `override:` option. They are separate methods rather than one relaxed
|
|
78
|
+
ordering, but they are still one class, and a reader has to notice which branch a reason came
|
|
79
|
+
from.
|
|
@@ -38,12 +38,28 @@ locked rather than the object it was handed.
|
|
|
38
38
|
- `ActiveRecord::StaleObjectError` maps to `StaleRequest`. `lock_version` is the belt to
|
|
39
39
|
`with_lock`'s braces.
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
`
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
**A distinct intent gets a distinct command class, never a flag.** Every command hard-codes the event
|
|
42
|
+
kind it emits, so a caller-supplied kind would be the first exception to that. `Commands::Override`
|
|
43
|
+
is `Execute` with `override: true`; `Commands::CancelUndeclared` is `Cancel` emitting
|
|
44
|
+
`operation_undeclared` instead of `canceled`. Both are **subclasses**, not delegations, so the rows
|
|
45
|
+
and events they write cannot drift from the command they wrap — there is nothing there to drift.
|
|
46
|
+
`Cancel` carries exactly two seams for it, the event kind and the metadata, and nothing else.
|
|
47
|
+
|
|
48
|
+
Three commands depart from the locking shape, each for a stated reason:
|
|
49
|
+
|
|
50
|
+
- `Commands::Create` has no row to lock until it has written one, so it wraps a transaction instead
|
|
51
|
+
— the request and its whole stage, quorum, permission and eligible-actor graph are all-or-nothing.
|
|
52
|
+
- `Commands::EvaluateWorkflow` ([ADR-0017](0017-approvals-count-through-links.md)) takes no lock of
|
|
53
|
+
its own: it is internal, invoked only from inside a caller that already holds one, and re-locking
|
|
54
|
+
would reload the row that caller has just written to.
|
|
55
|
+
- `Commands::Execute` takes none either, for the opposite reason to Create's: §8 forbids holding a
|
|
56
|
+
lock across the target invocation, and the three transactions it drives each take their own
|
|
57
|
+
([ADR-0022](0022-execution-in-three-transactions.md)).
|
|
58
|
+
|
|
59
|
+
`Commands::SettleExecution` is the one command that does not stamp the acting actor on its event. It
|
|
60
|
+
takes no actor at all: the executer's triple was recorded on the attempt when the claim was made, and
|
|
61
|
+
it reads that back — which is what lets a background job settle a claim it never made, and makes the
|
|
62
|
+
`executed` event name whoever actually claimed the run.
|
|
47
63
|
|
|
48
64
|
## Consequences
|
|
49
65
|
|
|
@@ -63,4 +79,9 @@ reload the row that caller has just written to.
|
|
|
63
79
|
— it catches a path no spec exercises — but it is a regular expression over the tree, and a
|
|
64
80
|
sufficiently creative write would slip past it.
|
|
65
81
|
- Commands return the request, except `Comment`, which returns the event it wrote, because the
|
|
66
|
-
request is unchanged by it
|
|
82
|
+
request is unchanged by it, and `Commands::ClaimExecution`, which returns the attempt its
|
|
83
|
+
successor has to finish.
|
|
84
|
+
- "Commands are the only writers" is now enforced across more classes than a reader expects:
|
|
85
|
+
`ClaimExecution`, `SettleExecution` and `Reap` are commands nobody calls directly, existing only
|
|
86
|
+
so that a sweep or a runner has something to write through. The alternative was a second event
|
|
87
|
+
path, which is the rule this record exists to keep.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# ADR-0022: Split execution into three transactions, and commit the claim before the side effect
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-12
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Executing a change request means invoking host code that the gem knows nothing about. It may take
|
|
9
|
+
seconds, call an external API, or die halfway through when the box is replaced.
|
|
10
|
+
|
|
11
|
+
Two requirements pull against each other. A request must never execute twice — that is the whole
|
|
12
|
+
promise of the approval gate, and a double charge is worse than no charge. And no row lock may be
|
|
13
|
+
held while the target runs, because a lock held across an outbound call blocks every reader of that
|
|
14
|
+
request for as long as the call takes, which on a bad day is the socket timeout.
|
|
15
|
+
|
|
16
|
+
The obvious shape — wrap guard, invoke and record in one `with_lock` — satisfies the first and
|
|
17
|
+
violates the second. It also loses the failure: if the target raises, the transaction rolls back and
|
|
18
|
+
takes the record of the failure with it, so the row looks untouched and nobody knows an attempt
|
|
19
|
+
happened.
|
|
20
|
+
|
|
21
|
+
## Decision
|
|
22
|
+
|
|
23
|
+
Three transactions, driven by `Execution::Runner`:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
T1 with_lock guard, claim the row, write the attempt, emit execution_started, COMMIT
|
|
27
|
+
T2 no lock invoke the target
|
|
28
|
+
T3 with_lock record the outcome on the request and the attempt, emit its event
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- **The claim is committed before the side effect runs.** That is what makes this stronger than one
|
|
32
|
+
lock around all three: between T1 and T3 the request is `executing` to every other process and to
|
|
33
|
+
the UI, and a second executor is refused by the guard rather than by luck.
|
|
34
|
+
- **T1's conditional `UPDATE … WHERE status IN ('approved','failed')`** is the invariant beneath the
|
|
35
|
+
guard. Zero rows means somebody else holds the claim, and nothing is invoked. With the guard inside
|
|
36
|
+
the same lock the ordinary race never reaches it — the loser is refused `:executing` first — so the
|
|
37
|
+
UPDATE is defence for the paths that skip the guard, not the primary mechanism.
|
|
38
|
+
- **The unique index on `(change_request_id, number)` is the claim's second lock.** Two processes
|
|
39
|
+
cannot both write attempt 3, whatever either believes about the status column.
|
|
40
|
+
- **T3's failure branch is its own transaction**, so a target that raises leaves no business change
|
|
41
|
+
and a durable record of the failure: `error_class`, `error_message` and a bounded backtrace on the
|
|
42
|
+
attempt, and an `execution_failed` event. The error is then re-raised as `TargetFailed` from inside
|
|
43
|
+
the rescue, so `#cause` is the target's own.
|
|
44
|
+
- Each transaction is a command ([ADR-0016](0016-commands-are-the-only-writers.md)):
|
|
45
|
+
`ClaimExecution` and `SettleExecution`, internal in the way `EvaluateWorkflow` is. The runner
|
|
46
|
+
orchestrates and writes nothing itself.
|
|
47
|
+
|
|
48
|
+
## Consequences
|
|
49
|
+
|
|
50
|
+
### Positive
|
|
51
|
+
|
|
52
|
+
- No lock is held across I/O, and that is measured rather than assumed: a `FOR UPDATE NOWAIT` attempt
|
|
53
|
+
while the target is blocked in T2 succeeds, and the same probe against a runner that collapses the
|
|
54
|
+
three into one lock fails. The control is what makes the measurement mean anything.
|
|
55
|
+
- A failed execution is fully recorded — which attempt, what raised, and when — while the business
|
|
56
|
+
change it attempted is absent.
|
|
57
|
+
- `executing` is a real, visible state, so the UI can show a request mid-flight instead of guessing
|
|
58
|
+
from the absence of an outcome.
|
|
59
|
+
- Because T1 commits on its own, background mode is a small change rather than a different design:
|
|
60
|
+
only T2 and T3 move ([ADR-0027](0027-activejob-is-optional.md)).
|
|
61
|
+
|
|
62
|
+
### Negative
|
|
63
|
+
|
|
64
|
+
- **A process that dies between T1 and T3 strands the row in `executing`**, which no command will
|
|
65
|
+
move. That is the direct cost of committing the claim, and it is why
|
|
66
|
+
`Maintenance.reap_stuck_executions!` and `Guards::Reap` exist at all
|
|
67
|
+
([ADR-0028](0028-sweeps-are-rake-tasks.md)). A crash-free design would have been simpler and would
|
|
68
|
+
have executed twice.
|
|
69
|
+
- The reaper's threshold is a guess about the host's slowest target. Set shorter than a legitimate
|
|
70
|
+
run, it writes off an execution that is still working: the row goes to `failed` while the target
|
|
71
|
+
keeps going, and nothing recalls it.
|
|
72
|
+
- Three transactions mean three chances to fail, and the second and third are not covered by the
|
|
73
|
+
first's rollback. A `SettleExecution` that cannot write is a stuck row the reaper later mislabels
|
|
74
|
+
as abandoned.
|
|
75
|
+
- `executing` is the one status no other command will move — `Cancel` refuses it — so the reaper is
|
|
76
|
+
the only way out, and a request whose declaration vanished while executing needed a third
|
|
77
|
+
exemption from the undeclared refusal to stay reachable at all.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# ADR-0023: One way to declare a workflow
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-12
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
M1b shipped `op.approvals permissions:, required:` — one stage, one quorum, no ceremony — as the
|
|
9
|
+
shorthand for the common case, with the full `op.workflow` block DSL planned for M2 to express
|
|
10
|
+
staged, multi-quorum policies.
|
|
11
|
+
|
|
12
|
+
When both existed they wrote the same `@workflow` slot. A declaration carrying one of each silently
|
|
13
|
+
kept whichever came last, with no error and no warning. The two had also already drifted: the same
|
|
14
|
+
number was `required:` in one and `threshold:` in the other, and nothing but attention kept the next
|
|
15
|
+
pair of spellings from diverging too.
|
|
16
|
+
|
|
17
|
+
## Decision
|
|
18
|
+
|
|
19
|
+
`op.workflow` is the only way to declare who approves. `op.approvals` is deleted, not deprecated.
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
op.workflow do |w|
|
|
23
|
+
w.stage :operational, satisfied_by: :all_quorums do |q|
|
|
24
|
+
q.quorum :admin, permissions: [{ actor_type: "Admin" }], threshold: 1
|
|
25
|
+
q.quorum :owners, permissions: %w(owner), threshold: 2
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
w.stage :director, permissions: %w(director), threshold: 1
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- A stage with one counting rule takes its `permissions` and `threshold` inline; a stage with more
|
|
33
|
+
takes a block. The two produce identical descriptions for the same policy, which is asserted
|
|
34
|
+
rather than assumed.
|
|
35
|
+
- **`threshold:` everywhere.** There is no second spelling to drift from.
|
|
36
|
+
- **The host names every stage.** The gem invents none, so `config/locales/en.yml` ships no stage or
|
|
37
|
+
quorum names at all and `name.humanize` covers everything unlisted.
|
|
38
|
+
- `Workflow::Builder` and `Workflow::StageBuilder` build a **description** and nothing else.
|
|
39
|
+
`Commands::Create` materialises whatever it is handed, so there is one producer and one
|
|
40
|
+
materialiser, not two of either.
|
|
41
|
+
- Declaration-time refusals for anything the database or the evaluator would otherwise refuse much
|
|
42
|
+
later: a quorum nobody qualifies for, a threshold below one, an unknown `match` or `satisfied_by`,
|
|
43
|
+
a duplicate stage or quorum name, a nameless stage, a stage block declaring no quorum, and a stage
|
|
44
|
+
given both a block and inline keywords.
|
|
45
|
+
|
|
46
|
+
## Consequences
|
|
47
|
+
|
|
48
|
+
### Positive
|
|
49
|
+
|
|
50
|
+
- One slot, one writer. The silent-overwrite failure is not fixed, it is unreachable.
|
|
51
|
+
- Mistakes are refused in the initializer where they were made, naming the stage and quorum, rather
|
|
52
|
+
than as a unique-index violation at creation or a request that sits pending until it expires.
|
|
53
|
+
- One normalisation of `permissions:` / `actor_type:` / `eligible_actors:` / `match:`, so the inline
|
|
54
|
+
and block forms cannot disagree about what a quorum means.
|
|
55
|
+
|
|
56
|
+
### Negative
|
|
57
|
+
|
|
58
|
+
- Every host declaration changes. There is no deprecation path and no shim — acceptable only because
|
|
59
|
+
nothing was released, and this record would read very differently otherwise.
|
|
60
|
+
- The common case got longer. `op.approvals permissions: %w(admin), required: 2` became a three-line
|
|
61
|
+
block, and that is a real cost paid by every operation to remove an overwrite bug most hosts would
|
|
62
|
+
never have hit.
|
|
63
|
+
- A quorum name is null when its stage holds one, and a string when it holds several, so event
|
|
64
|
+
metadata carries the key sometimes and omits it otherwise. Readers of the trail have to handle
|
|
65
|
+
both.
|
|
66
|
+
- `op.workflow` is the declarer and its reader is `workflow` with no block — a convention that
|
|
67
|
+
worked because a block disambiguates, and which `op.override` then could not follow
|
|
68
|
+
([ADR-0026](0026-distinct-intent-distinct-command.md)).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# ADR-0024: Require idempotence of every target rather than declaring it per operation
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-12
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
A failed execution can be retried. Whether that is safe depends entirely on the target: running
|
|
9
|
+
`Member#update!` twice is harmless, charging a card twice is not.
|
|
10
|
+
|
|
11
|
+
`Operation` carried an `op.idempotent` flag, defaulting to `false`, intended to gate retries. By the
|
|
12
|
+
time execution was built, nothing read it — and writing the code that would have read it made the
|
|
13
|
+
problem visible. The flag is a promise about host code that the gem cannot verify, cannot test, and
|
|
14
|
+
would have to trust completely while deciding whether to repeat a side effect.
|
|
15
|
+
|
|
16
|
+
## Decision
|
|
17
|
+
|
|
18
|
+
The flag is removed. **Every change-request target must be idempotent**, and the README says so as a
|
|
19
|
+
contract rather than an option:
|
|
20
|
+
|
|
21
|
+
> A change-request target is a public singleton method that accepts keyword arguments only, and whose
|
|
22
|
+
> effect is idempotent — running it twice with the same payload leaves the same result as running it
|
|
23
|
+
> once.
|
|
24
|
+
|
|
25
|
+
- `retryable?` is `failed? && attempts.count < max_attempts`, and nothing else. The **retry ceiling
|
|
26
|
+
is the only bound**, because it is the only one the gem can enforce.
|
|
27
|
+
- A host that cannot make a target idempotent leaves `op.max_attempts` at its default of `1` and gets
|
|
28
|
+
one attempt. That is the same outcome `idempotent: false` would have produced, declared in terms of
|
|
29
|
+
a number the gem actually acts on.
|
|
30
|
+
- A target declaring `change_request_id:` receives it, stable across every attempt, so one calling an
|
|
31
|
+
external API can hand it over as that API's idempotency key. A per-attempt token would have defeated
|
|
32
|
+
exactly the case retries exist for.
|
|
33
|
+
- `rake change_requests:verify` checks the checkable half of the contract — the constant resolves, it
|
|
34
|
+
answers the singleton method dispatch will call, and it takes keyword arguments only. Idempotence it
|
|
35
|
+
does not check, and does not pretend to.
|
|
36
|
+
|
|
37
|
+
## Consequences
|
|
38
|
+
|
|
39
|
+
### Positive
|
|
40
|
+
|
|
41
|
+
- One fewer setting whose value the gem has to believe. `max_attempts` is a number with observable
|
|
42
|
+
behaviour; `idempotent: true` was an assertion with none.
|
|
43
|
+
- The requirement is stated once, in the README, where a host writing their first target reads it —
|
|
44
|
+
rather than implied by a default they would have had to reason about.
|
|
45
|
+
- `Request#retryable?` needed no change when execution was finally built, because it had never
|
|
46
|
+
learned about the flag.
|
|
47
|
+
|
|
48
|
+
### Negative
|
|
49
|
+
|
|
50
|
+
- **The gem now depends on a property it cannot check.** A host that writes a non-idempotent target
|
|
51
|
+
and leaves `max_attempts` above 1 gets a double side effect, and nothing in the gem will have
|
|
52
|
+
warned them. The flag would not have prevented this either — it would have recorded the same
|
|
53
|
+
unverified claim — but its absence makes the reliance explicit rather than ceremonial.
|
|
54
|
+
- `max_attempts = 1` now carries two meanings: "this is cheap to retry but rarely worth it" and "this
|
|
55
|
+
must never run twice". A reader of a declaration cannot tell which was meant.
|
|
56
|
+
- Removing a public attribute is a breaking change for anyone who set it. Acceptable only because
|
|
57
|
+
nothing was released.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# ADR-0025: Verify declarations at boot, from the same checks the runtime reads
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-12
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
An operation can be wrong in ways nothing notices until the worst moment. A `service` naming a
|
|
9
|
+
constant that does not exist fails when someone finally executes a request two approvals in. A
|
|
10
|
+
workflow with no stages produces a request that can never leave `pending`.
|
|
11
|
+
|
|
12
|
+
`Commands::Create` already refused some of these at creation, in its own words. §6.12 promised a
|
|
13
|
+
boot-time `verify!` that would refuse more of them. Written independently, those two would answer the
|
|
14
|
+
same question differently — and a third reader was already planned, `Operation#requestable_by?` for
|
|
15
|
+
the "raise a request" button that has no guard to consult.
|
|
16
|
+
|
|
17
|
+
## Decision
|
|
18
|
+
|
|
19
|
+
One set of checks, split by what they need in order to run:
|
|
20
|
+
|
|
21
|
+
- **`Operation#problems`** — everything checkable without loading the host's classes: a `version`, a
|
|
22
|
+
`service`, a `method_name`, a non-empty workflow, a positive threshold on every quorum. Read by
|
|
23
|
+
`validate!` at declaration, by `Commands::Create` at creation, and by `verify!` at boot. An
|
|
24
|
+
incomplete operation therefore never enters the registry at all; `Create`'s check is the backstop
|
|
25
|
+
for one edited afterwards.
|
|
26
|
+
- **`Operation#target_problems`** — what needs the host's classes loaded, so it runs at boot and
|
|
27
|
+
nowhere else: the constant resolves, and it answers the public singleton method dispatch will call,
|
|
28
|
+
taking keyword arguments only. Constantizing during declaration would hold references to classes an
|
|
29
|
+
initializer has not finished defining, and in a reloading application to classes about to be
|
|
30
|
+
replaced.
|
|
31
|
+
- **`Execution::TargetContract`** holds the target half and its wording, read by `verify!` at boot and
|
|
32
|
+
by `Execution::Dispatcher` at dispatch. The two cannot describe the same defect differently.
|
|
33
|
+
- `Operations#verify!` raises one `ConfigurationError` listing every problem across every operation,
|
|
34
|
+
the shape `Configuration#validate!` already uses. A host that has seen one has seen both.
|
|
35
|
+
- Two entry points: `rake change_requests:verify` for CI and deploys, and a `to_prepare` hook
|
|
36
|
+
registered in the engine and gated on `Rails.env.local?`. Production runs the task instead, because
|
|
37
|
+
`verify!` constantizes every declared service and a booted application should not pay for that on
|
|
38
|
+
every request cycle.
|
|
39
|
+
|
|
40
|
+
## Consequences
|
|
41
|
+
|
|
42
|
+
### Positive
|
|
43
|
+
|
|
44
|
+
- A misconfigured operation fails in CI, or on the next reload in development, rather than on the
|
|
45
|
+
first execution of a request someone has already approved.
|
|
46
|
+
- Boot-time and creation-time cannot word the same defect differently, because they read the same
|
|
47
|
+
method — asserted by a spec that compares the two messages.
|
|
48
|
+
- The `to_prepare` hook holds nothing across a reload, which is proven by replacing the resolved
|
|
49
|
+
class and watching the next run refuse.
|
|
50
|
+
|
|
51
|
+
### Negative
|
|
52
|
+
|
|
53
|
+
- `validate!` growing to the full `#problems` set made declaration stricter than it was. Every
|
|
54
|
+
incomplete fixture in the suite had to gain a service and a workflow, and a host that liked
|
|
55
|
+
declaring an operation in pieces across two initializers no longer can.
|
|
56
|
+
- **`verify!` does not check everything §6.12 claims.** An unsatisfiable `all_quorums` stage is still
|
|
57
|
+
unchecked, because "unsatisfiable" is undecidable once permission rows are involved — eligibility is
|
|
58
|
+
a host runtime question. The gap is recorded in §17.1 rather than papered over.
|
|
59
|
+
- The `to_prepare` hook runs on every reload in development, so a large registry pays a constantize
|
|
60
|
+
per cycle. Cheap today; a host with hundreds of operations may disagree.
|
|
61
|
+
- Three readers of `#problems` means changing it changes three behaviours at once. That is the point,
|
|
62
|
+
and it is also the risk.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# ADR-0026: Give a distinct intent a distinct command class, not a flag
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-12
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Two transitions in the gem are the same mechanics with a different meaning.
|
|
9
|
+
|
|
10
|
+
Executing with `override: true` does what executing does, on a request that never reached `approved`
|
|
11
|
+
— but §8.1 wants the exception to *look* like one, and `Execute.call(…, override: true)` buried in a
|
|
12
|
+
controller does not. Cancelling because a declaration vanished writes the same columns an ordinary
|
|
13
|
+
cancellation writes, but "nobody decided this, its declaration disappeared" is a different fact from
|
|
14
|
+
"someone called it off", and a timeline should not have to infer which from the actor column.
|
|
15
|
+
|
|
16
|
+
Both could have been a boolean. Both would then have needed a caller-supplied event kind, which no
|
|
17
|
+
command in the gem has: every one hard-codes the kind it emits.
|
|
18
|
+
|
|
19
|
+
## Decision
|
|
20
|
+
|
|
21
|
+
A distinct intent gets a distinct command class, implemented as a **subclass** of the command it
|
|
22
|
+
wraps:
|
|
23
|
+
|
|
24
|
+
- `Commands::Override < Execute` — `.call(request:, actor:, reason:)`, which is `Execute` with
|
|
25
|
+
`override: true`. It adds a name and nothing else.
|
|
26
|
+
- `Commands::CancelUndeclared < Cancel` — emits `operation_undeclared` instead of `canceled`, and
|
|
27
|
+
adds the operation key and the request's creation-time version to the metadata. `Cancel` carries
|
|
28
|
+
exactly two seams for it, the event kind and the metadata, and is otherwise untouched.
|
|
29
|
+
|
|
30
|
+
Subclassing rather than delegation is deliberate: the rows and events the wrapper writes **cannot**
|
|
31
|
+
drift from the command it wraps, because there is nothing in the wrapper to drift. The equivalence is
|
|
32
|
+
asserted anyway, by comparing both snapshots.
|
|
33
|
+
|
|
34
|
+
The same rule decides which event a cancellation emits. **Who cancelled decides**: a person
|
|
35
|
+
cancelling a stranded request emits `canceled` with their own reason, because they cancelled it; the
|
|
36
|
+
sweeper emits `operation_undeclared`, because nobody did.
|
|
37
|
+
|
|
38
|
+
## Consequences
|
|
39
|
+
|
|
40
|
+
### Positive
|
|
41
|
+
|
|
42
|
+
- M5's presenter renders `:execute` and `:execute_override` as two actions — the second `tone:
|
|
43
|
+
:danger` and always confirmed — without branching on a boolean.
|
|
44
|
+
- A timeline records the fact, not the mechanism. Filtering for `operation_undeclared` finds every
|
|
45
|
+
request closed out by a vanished declaration, with no join to the actor column.
|
|
46
|
+
- Every command still hard-codes its own event kind, so the "one kind per class" rule holds without
|
|
47
|
+
exception.
|
|
48
|
+
|
|
49
|
+
### Negative
|
|
50
|
+
|
|
51
|
+
- Two classes exist that add almost no code, and a reader looking for the override logic finds it in
|
|
52
|
+
`Guards::Execute` and `ClaimExecution` rather than in `Commands::Override`. The name is the point,
|
|
53
|
+
but the name is also all there is.
|
|
54
|
+
- `Commands::Cancel` carries two seams it never uses itself, which is a small permanent cost paid so
|
|
55
|
+
a subclass can exist.
|
|
56
|
+
- The convention did not extend cleanly to declarations. `op.override` is a declarer whose reader had
|
|
57
|
+
to be named `override_policy`, because — unlike `op.workflow` — it has no block to distinguish
|
|
58
|
+
"declare this" from "tell me what was declared", and a bare `op.override` that read instead of
|
|
59
|
+
declaring would have left the gate shut while looking open.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# ADR-0027: Make background execution a setting and ActiveJob an optional dependency
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-12
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
A target that calls an external API should not run in the request cycle. §8 therefore offers
|
|
9
|
+
`config.execution_mode = :background`, moving the invocation and its outcome into an ActiveJob job.
|
|
10
|
+
|
|
11
|
+
[ADR-0001](0001-headless-domain-core.md) says the domain core must load and run with no Rails at all —
|
|
12
|
+
from a job, a console, an API or a rake task. ActiveJob is a Rails framework. Adding it as a runtime
|
|
13
|
+
dependency to support an optional mode would make every headless adopter carry it, and
|
|
14
|
+
[ADR-0014](0014-executable-architecture-rules.md) forbids the domain from naming the constant at all.
|
|
15
|
+
|
|
16
|
+
## Decision
|
|
17
|
+
|
|
18
|
+
`execution_mode` is an ordinary setting, and ActiveJob is not a dependency.
|
|
19
|
+
|
|
20
|
+
- `ChangeRequests::Execution::Job` lives in a file Zeitwerk **ignores**, required by
|
|
21
|
+
`ChangeRequests.load_execution_job!` on the same terms `load_engine!` already established:
|
|
22
|
+
idempotent, public, guarded by `defined?(::ActiveJob::Base)`. Requiring it without ActiveJob defines
|
|
23
|
+
nothing at all. The engine hooks `ActiveSupport.on_load(:active_job)`, so a host never calls it.
|
|
24
|
+
- **T1 commits synchronously in both modes**, so the request shows `executing` the moment the call
|
|
25
|
+
returns and the UI never has to guess. Only T2 and T3 move
|
|
26
|
+
([ADR-0022](0022-execution-in-three-transactions.md)).
|
|
27
|
+
- The job takes two ids, not two objects — a job argument has to survive serialisation. It can settle
|
|
28
|
+
a claim it never made because the executer's triple was recorded on the attempt at claim time.
|
|
29
|
+
- `validate!` deliberately does **not** check that `job_class` resolves. A headless process may have
|
|
30
|
+
`:background` configured and no ActiveJob, and refusing that would fail a boot that works.
|
|
31
|
+
`ChangeRequests.background_available?` answers the question, and the enqueue reports it, naming
|
|
32
|
+
ActiveJob.
|
|
33
|
+
- `config.job_class` is a string, resolved at enqueue time and never held: a reloading application
|
|
34
|
+
redefines it.
|
|
35
|
+
|
|
36
|
+
## Consequences
|
|
37
|
+
|
|
38
|
+
### Positive
|
|
39
|
+
|
|
40
|
+
- A headless adopter carries no ActiveJob and loses nothing they were using. That is asserted in a
|
|
41
|
+
subprocess: `:background` configured, `validate!` true, `background_available?` false, and a clear
|
|
42
|
+
refusal on enqueue.
|
|
43
|
+
- The UI shows `executing` immediately in both modes, so background is a deployment choice rather than
|
|
44
|
+
a different user experience.
|
|
45
|
+
- Nothing in the domain names `ActiveJob`, except two `archspec:disable` lines carrying the reason.
|
|
46
|
+
|
|
47
|
+
### Negative
|
|
48
|
+
|
|
49
|
+
- A file Zeitwerk ignores is a file the eager-load check does not cover, so a constant misplaced in
|
|
50
|
+
`execution/job.rb` fails at a host's first enqueue rather than in this gem's suite.
|
|
51
|
+
- The "is it available" question has three answers depending on when it is asked — at require time,
|
|
52
|
+
after `on_load`, and at enqueue — and only the last is authoritative.
|
|
53
|
+
- `execution_mode = :background` is valid in a process that can never perform it. That is the price of
|
|
54
|
+
not failing a headless boot, and it means the misconfiguration surfaces at the first execution
|
|
55
|
+
rather than at deploy.
|