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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -0
  3. data/config/locales/en.yml +7 -0
  4. data/docs/05_execution_and_idempotency.md +129 -0
  5. data/docs/adr/0015-one-guard-object-per-transition.md +25 -6
  6. data/docs/adr/0016-commands-are-the-only-writers.md +28 -7
  7. data/docs/adr/0022-execution-in-three-transactions.md +77 -0
  8. data/docs/adr/0023-one-declaration-surface.md +68 -0
  9. data/docs/adr/0024-idempotence-is-required-not-declared.md +57 -0
  10. data/docs/adr/0025-verification-from-one-set-of-checks.md +62 -0
  11. data/docs/adr/0026-distinct-intent-distinct-command.md +59 -0
  12. data/docs/adr/0027-activejob-is-optional.md +55 -0
  13. data/docs/adr/0028-sweeps-are-rake-tasks.md +67 -0
  14. data/docs/adr/README.md +7 -0
  15. data/lib/change_requests/commands/cancel.rb +11 -1
  16. data/lib/change_requests/commands/cancel_undeclared.rb +46 -0
  17. data/lib/change_requests/commands/reap.rb +37 -0
  18. data/lib/change_requests/commands/settle_execution.rb +27 -3
  19. data/lib/change_requests/configuration.rb +35 -0
  20. data/lib/change_requests/engine.rb +7 -0
  21. data/lib/change_requests/execution/job.rb +27 -0
  22. data/lib/change_requests/execution/runner.rb +31 -2
  23. data/lib/change_requests/guards/base.rb +2 -0
  24. data/lib/change_requests/guards/cancel.rb +9 -1
  25. data/lib/change_requests/guards/reap.rb +59 -0
  26. data/lib/change_requests/maintenance.rb +48 -0
  27. data/lib/change_requests/models/request.rb +18 -0
  28. data/lib/change_requests/version.rb +1 -1
  29. data/lib/change_requests.rb +37 -1
  30. data/lib/tasks/change_requests.rake +39 -1
  31. metadata +14 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5121ac3d2657395d7e5587ab4cbed6ef9338bb17a15be4c7252ebd09a9a68202
4
- data.tar.gz: 1255001fa45221fee226d66e986666e2a6b5f8fb417ffea8c89bb9069c8e7c9d
3
+ metadata.gz: 60c060656fc690b5b61123d202e1a3c857836d978cf378ec1275c923863c78c7
4
+ data.tar.gz: 629a3e88dde76ecc8e2d73c63948050d409fe19bc7ccc07704579cbb6b5098cf
5
5
  SHA512:
6
- metadata.gz: 0627a54b60fe7cf7d2ad407f2f52227b28605b36a4c4b97bdb833a532155962d9d9dcde116feeb38d7b6ad13c154809be2b000de39e0d4e689950aa5ee2887b6
7
- data.tar.gz: cb8d036a00b8f9cca8dab2d0ff00c9df9b0737662ea913072b93dce704d239cb39ae4bb46d203c9b93bdce729eceeefbe2da15f705980e235952e58adeec2bc0
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
@@ -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 `Expire` refuse with `NotAuthorized`, the decision guards with their own
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
- - One reason overrides that declaration: `:already_finalized` always raises `AlreadyFinalized`,
30
- whichever guard produced it, so a host rescuing "this request is over" catches every command. It
31
- is the same class and the same reason the model's terminal-state guard raises underneath.
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. `Comment` opts out with `exempt_from_undeclared_operation!` a request stranded by a
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
- Two commands depart from the shape, each for a stated reason. `Commands::Create` has no row to lock
42
- until it has written one, so it wraps a transaction instead the request and its whole stage,
43
- quorum, permission and eligible-actor graph are all-or-nothing.
44
- `Commands::EvaluateWorkflow` ([ADR-0017](0017-approvals-count-through-links.md)) takes no lock of its
45
- own: it is internal, invoked only from inside a caller that already holds one, and re-locking would
46
- reload the row that caller has just written to.
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.