change_requests 0.2.2 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 205a020929c71027848bc891897a26676a96fbc2c77a6d2e32aa5cedc577109c
4
- data.tar.gz: 172b2e90ff436bd10e42e5a30eb18052b65b20bf660156efec238b58aa58abf5
3
+ metadata.gz: 7790d14f78ffa0b667712d827db6cc77d527dd9f733582b95b0a43ca4372d156
4
+ data.tar.gz: 21b70a37fe29a796e855571f82901d1e098328ec5b0d567335c5982b0622ccce
5
5
  SHA512:
6
- metadata.gz: a6b83acddf5fc81320a9cb370e2212b5ae173c2a73e838247082ac97222cf404b56a446f37819d9b681f0c4ba1a05280d81dd1e164a0a35d650fd9f0dd1f9ed3
7
- data.tar.gz: '0013798bab6a0a057b10a9100fb751cbf85589a31502284b79e1612141b341494bd5e12b6a978b56be3ba6f2842ecf06b203abc033965ed32d62581575858dbe'
6
+ metadata.gz: 9fe5fea5c0e07c85cbac8ca55ec947ce59d1a999ec9cfc99d3cd1beeb0615ced473e41ae89f2a2561f7edfeb20c24102a751ffe4eb4b84111df3abff8ca234fd
7
+ data.tar.gz: bb3d6b92463ba8c4833400c35b99f1de62fc0304e31203b2d34e8f6e3ef3aef81aba2c4c83886cf296db76f3526d473ca2cea1a01c53ccfce56613d454746a94
@@ -0,0 +1,55 @@
1
+ # Every message a guard or a command can put in front of a person (§7, §5.9).
2
+ #
3
+ # `reason` is the contract - controllers branch on the symbol, views render it as a disabled
4
+ # button's tooltip - and these strings are what the tooltip says. They are written for the person
5
+ # being refused, not for the developer reading a log: none of them names a configuration key.
6
+ #
7
+ # Nothing here is required. `ChangeRequests::Translation` passes a usable default to every lookup,
8
+ # so the domain core keeps working with no locale file loaded at all - which is exactly what the
9
+ # headless spec asserts, where the message *is* the reason symbol.
10
+ en:
11
+ change_requests:
12
+ errors:
13
+ # One key per entry of Guards::Base::REASONS.
14
+ already_decided: "You have already decided this step."
15
+ already_finalized: "This request is already finished."
16
+ attempts_exhausted: "This request has used all of its attempts."
17
+ body_required: "A comment needs something in it."
18
+ executing: "This request is running right now."
19
+ may_not_request: "Your kind of account cannot raise change requests."
20
+ not_approved: "This request has not been approved yet."
21
+ not_expirable: "Only a pending or approved request can expire."
22
+ not_expired: "This request has not reached its expiry date."
23
+ not_pending: "This request is no longer open for decisions."
24
+ not_permitted: "You are not one of this request's approvers."
25
+ not_system: "Expiry is the clock's to decide, not yours."
26
+ not_the_approver: "You can only take back your own decision."
27
+ operation_undeclared: "This request's operation no longer exists, so it can never run."
28
+ reason_required: "A reason is required."
29
+ requester: "You cannot decide on your own request."
30
+ stage_not_current: "This step is not the one waiting on you."
31
+ stage_not_open: "This step is closed."
32
+
33
+ # One key per error class that carries a reason, used when a caller raises without one -
34
+ # Refusal#i18n_key falls back to the class name underscored.
35
+ not_approvable: "This request cannot be approved."
36
+ not_authorized: "You are not allowed to do that."
37
+ not_cancelable: "This request cannot be cancelled."
38
+ not_executable: "This request cannot be executed."
39
+ not_rejectable: "This request cannot be rejected."
40
+ not_unapprovable: "This decision cannot be taken back."
41
+ override_not_permitted: "You are not allowed to override the approvals."
42
+ quorum_not_met: "This request does not have the approvals it needs."
43
+ transition_error: "This request will not accept that."
44
+
45
+ # Stage and quorum names are declaration identifiers, not display text (§5.9). A host adds a key
46
+ # per name it declares; anything unlisted falls back to `name.humanize`, so "sign_off" reads as
47
+ # "Sign off" with no locale entry at all.
48
+ #
49
+ # stages:
50
+ # sign_off: "Director sign-off"
51
+ # quorums:
52
+ # owners: "Owners"
53
+ stages:
54
+ # The only name the gem itself creates - `op.approvals` builds one stage called this.
55
+ approval: "Approval"
@@ -6,7 +6,9 @@
6
6
  ## Context
7
7
 
8
8
  Some transitions have no human behind them: a request expiring, a reaper closing out an
9
- interrupted execution, a cleanup task cancelling requests whose operation was removed. The audit
9
+ interrupted execution, a cleanup task cancelling requests whose operation was removed, and — the
10
+ most frequent case by far — **every stage closing**, since workflow evaluation is an internal command
11
+ with no actor of its own ([ADR-0017](0017-approvals-count-through-links.md)). The audit
10
12
  trail still has to say who did it.
11
13
 
12
14
  Leaving the actor columns NULL makes every reader handle a null case, and makes "nobody" and "we
@@ -17,11 +17,17 @@ controller, with finer branches for the cases they handle specially. That code d
17
17
  Everything descends from `ChangeRequests::Error`; refused transitions descend from
18
18
  `TransitionError`, execution failures from `ExecutionError`.
19
19
 
20
- `TransitionError` carries `#request` and `#reason`. **`reason` is the contract** — controllers branch
20
+ Every refusal carries `#request` and `#reason`. **`reason` is the contract** — controllers branch
21
21
  on it and views render it as a disabled button's tooltip — while the message is for humans and is
22
22
  translated through the same key the guard uses, so a disabled button and a raised error cannot word
23
23
  the same refusal differently.
24
24
 
25
+ That pair lives in a `Refusal` **module**, included by `TransitionError` *and* by `NotAuthorized`. A
26
+ shared base class would have made `NotAuthorized` a member of the transition family, and it is
27
+ deliberately a sibling: "the actor may never do this" is a different answer from "not yet", and hosts
28
+ rescue them apart. A guard raises whichever class it declared
29
+ ([ADR-0015](0015-one-guard-object-per-transition.md)) and the host branches on `#reason` either way.
30
+
25
31
  Errors raised for unsupported operations rather than refused domain transitions stay in ActiveRecord's
26
32
  taxonomy; see [ADR-0007](0007-append-only-audit-trail.md).
27
33
 
@@ -0,0 +1,60 @@
1
+ # ADR-0015: Consult one guard object for every transition
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-12
5
+
6
+ ## Context
7
+
8
+ Every transition is asked about twice: once by the view, to decide whether the button is enabled,
9
+ and once by the command, to decide whether the write happens. Answering it twice means two
10
+ implementations of the same rule, and the failure mode is a button that offers something the command
11
+ then refuses — with different wording, or no wording at all.
12
+
13
+ The rules themselves are not one condition but an ordered list of them. "You cannot approve this"
14
+ covers a finished request, a request on another stage, a decision already given, and an actor with
15
+ no standing at all. Which of those a person is told is the whole of the user experience.
16
+
17
+ ## Decision
18
+
19
+ One guard class per transition, under `lib/change_requests/guards/`, constructed as
20
+ `(request:, actor:, **options)`. It answers `allowed?`, `reason` and `check!`, and both the command
21
+ and the presenter build the same object.
22
+
23
+ - A subclass implements `refusal`, which returns `nil` to permit or a symbol from
24
+ `Guards::Base::REASONS`, the closed shared vocabulary. Branch order inside `refusal` is part of
25
+ the contract: it decides which of several true refusals the person is shown.
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
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.
32
+ - 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.
35
+
36
+ **There is no `Guards::Create`.** A guard asks "may this actor do X *to this row*", and at creation
37
+ there is no row. `Commands::Create` runs the three equivalent checks inline. The presenter's half of
38
+ the question — may this actor raise a request at all — is a property of the operation and the actor's
39
+ registered type, and will be answered by `Operation#requestable_by?` reading the same
40
+ implementation, not by a guard carrying a second signature.
41
+
42
+ ## Consequences
43
+
44
+ ### Positive
45
+
46
+ - A disabled button and a raised error cannot disagree, because they are the same object.
47
+ - `reason` is a symbol, so a host branches on it without parsing English, and the wording is a
48
+ translation rather than a string in the code ([ADR-0020](0020-refusal-vocabulary-and-fallback.md)).
49
+ - One shape for every guard means one shared example can run against all of them — which is how the
50
+ undeclared-operation exemption and the cross-guard reason table are asserted at all.
51
+
52
+ ### Negative
53
+
54
+ - `REASONS` is public API. A symbol cannot be renamed without breaking host code that branches on
55
+ it, and the vocabulary grows monotonically.
56
+ - Branch order is load-bearing but invisible: nothing in the type system says `:already_finalized`
57
+ must precede `:not_pending`, only a cross-guard spec that asserts the two answers line up. They
58
+ had already drifted once before that spec existed.
59
+ - A guard resolves the acting actor through the registry, so an unregistered class raises
60
+ `UnknownActorType` rather than producing a refusal. The allowlist is deliberately not a reason.
@@ -0,0 +1,66 @@
1
+ # ADR-0016: Make commands the only writers, under one lock and one event path
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-12
5
+
6
+ ## Context
7
+
8
+ A request's state lives in several rows: the request, its current stage, its quorums, an approval and
9
+ its quorum links, and an event. A transition touches most of them. If callers assemble those writes
10
+ themselves, every call site is a chance to write four of the five, and the audit trail is the first
11
+ thing to be forgotten because it is the only one nothing else depends on.
12
+
13
+ Concurrency makes the same point sharper. Two approvals arriving together must not both believe they
14
+ were the last one needed.
15
+
16
+ ## Decision
17
+
18
+ One command class per transition, under `lib/change_requests/commands/`. `Commands::Base.call`
19
+ resolves the guard, performs the writes and emits the event, and **wraps the whole body in
20
+ `request.with_lock`** — `SELECT … FOR UPDATE`, which also reloads, so a command reads the row it
21
+ locked rather than the object it was handed.
22
+
23
+ - **Commands raise.** There is no result object and no `success?`. A refusal is an exception carrying
24
+ a reason ([ADR-0012](0012-declared-error-taxonomy.md)), because the caller that ignores a returned
25
+ failure is the caller that writes the bug.
26
+ - **Commands never accept permissions from the caller.** They take an actor; the gem resolves that
27
+ actor's permissions through the registry ([ADR-0018](0018-eligibility-is-data.md)). A
28
+ caller-supplied permission set is unverifiable by the gem and untestable by the host.
29
+ - **`emit` is the single write path for events.** It stamps the actor triple (or the System
30
+ sentinel, [ADR-0011](0011-system-sentinel-actor.md)), `occurred_at`, and the `operation_version`
31
+ read live from the declaration — deliberately not the request's creation-time value, which the row
32
+ already holds. The rule is enforced by a spec that scans `lib/` and fails if any other file writes
33
+ an event.
34
+ - **Lost races surface as domain refusals.** `on_conflict` declares which `TransitionError` an
35
+ `ActiveRecord::RecordNotUnique` becomes, so the loser of a duplicate-decision race is told
36
+ `:already_decided` rather than getting a 500. An unmapped conflict is re-raised: that is a bug to
37
+ see, not a refusal to dress it up as.
38
+ - `ActiveRecord::StaleObjectError` maps to `StaleRequest`. `lock_version` is the belt to
39
+ `with_lock`'s braces.
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.
47
+
48
+ ## Consequences
49
+
50
+ ### Positive
51
+
52
+ - Every state change goes through one place per transition, so the event, the status and the rows
53
+ cannot drift apart.
54
+ - Two command bodies are never inside the same request at once; that is asserted by measuring
55
+ overlap on real threads rather than by removing the lock and hoping the scheduler cooperates
56
+ ([ADR-0021](0021-serialise-commands-let-the-index-arbitrate.md)).
57
+ - A host rescues `ChangeRequests::Error` once and gets a flash instead of an exception page.
58
+
59
+ ### Negative
60
+
61
+ - Every transition holds a row lock for the duration of its writes, including the event insert.
62
+ - The `emit` invariant is enforced by scanning source text. It is stronger than a runtime assertion
63
+ — it catches a path no spec exercises — but it is a regular expression over the tree, and a
64
+ sufficiently creative write would slip past it.
65
+ - Commands return the request, except `Comment`, which returns the event it wrote, because the
66
+ request is unchanged by it.
@@ -0,0 +1,77 @@
1
+ # ADR-0017: Count approvals only through the links written at decision time
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-12
5
+
6
+ ## Context
7
+
8
+ "Is this stage satisfied?" can be answered two ways. Either re-evaluate every approver against every
9
+ quorum whenever something changes, or record at decision time which quorums an approval counted
10
+ toward and count those rows afterwards.
11
+
12
+ Re-derivation has a failure mode that is easy to miss and impossible to explain afterwards: an
13
+ approver's permissions change, the re-evaluation no longer matches them to the quorum they approved,
14
+ and a request that was approved quietly is not any more. Nobody did anything, and the audit trail
15
+ shows nothing, because nothing happened.
16
+
17
+ ## Decision
18
+
19
+ `change_request_approval_quorums` records which quorums an approval counted toward, written by the
20
+ command at decision time and **never re-derived**
21
+ ([ADR-0007](0007-append-only-audit-trail.md) makes the rows immutable). A quorum is satisfied when
22
+ its linked approvals reach its `threshold`, and that count is the only rule.
23
+
24
+ `ChangeRequests::Commands::EvaluateWorkflow` is the only code that changes stage or request status as
25
+ a consequence of a decision. It is a command like any other
26
+ ([ADR-0016](0016-commands-are-the-only-writers.md)) but internal: no actor, never called by a host,
27
+ invoked only from `Approve`, `Unapprove` and `Reject` inside the lock they already hold. It runs in
28
+ order:
29
+
30
+ 0. A rejection standing on the current stage makes it `rejected`, whatever its approvals say; a stage
31
+ that was rejected and holds none any more returns to `pending`, with its approvals still counting.
32
+ 1. Recount every quorum of the current stage from its links, setting or clearing `satisfied_at`.
33
+ 2. The stage is satisfied when **any** of its quorums is, or **all**, per `satisfied_by`.
34
+ 3. A satisfied stage closes immediately, and the request advances to the next stage — or becomes
35
+ `approved` when none remains.
36
+
37
+ Closing emits one `quorum_satisfied` per satisfied quorum and then one `stage_satisfied`, both
38
+ attributed to the System sentinel ([ADR-0011](0011-system-sentinel-actor.md)). Closing a stage is the
39
+ gem's own act, not the approver's: the approvals that caused it are already in the trail one row
40
+ earlier, each naming the person who gave it, and attributing the close to whoever approved last would
41
+ assert a decision that person never made.
42
+
43
+ **Closed stages are immutable and there is no rollback into an earlier one.** A stage's outcome, once
44
+ closed, is a historical fact. Rejection is a stop rather than a count: one rejection from an eligible
45
+ approver or from the requester stops the stage, and no rejection threshold is modelled.
46
+
47
+ ## Consequences
48
+
49
+ ### Positive
50
+
51
+ - A later role change cannot silently un-approve a request. The links say what was true when the
52
+ decision was made, which is the only moment at which it was a decision.
53
+ - Counting is a `GROUP BY` over an indexed table rather than a re-evaluation of every approver
54
+ against every quorum.
55
+ - Step 0 is correct at every cooldown value, so the window that a later milestone puts between
56
+ stopping a stage and finalising the request extends this command instead of rewriting it.
57
+
58
+ ### Negative
59
+
60
+ - **The `all_quorums` linking rule is not implemented yet.** Under `any_quorum` an approval links to
61
+ every quorum the actor qualifies for, which is right, because satisfying any one of them ends the
62
+ stage. Under `all_quorums` it should link to exactly one — the lowest-`position` quorum the actor
63
+ qualifies for — so that one person holding two roles cannot close two quorums that must both be
64
+ met. It currently links to all of them, so an actor holding both `admin` and `owner` satisfies
65
+ "one Admin **and** one Owner" alone. The stage-satisfaction half of `all_quorums` ships and is
66
+ correct; only the linking half is outstanding. No shipped declaration syntax can build such a
67
+ stage — `op.approvals` describes one stage holding one quorum — so nothing reaches it today, and
68
+ it must be closed before the workflow DSL makes multi-quorum stages declarable.
69
+ - **`quorum_satisfied` is emitted when the stage closes, not when the quorum was met.** For a
70
+ single-quorum stage the two moments are the same. Under `all_quorums` they are not: a quorum met by
71
+ an earlier approval gets its event later, timestamped at the close, and a quorum on a stage that
72
+ never closes gets none at all. The trail never claims a satisfaction that was later withdrawn,
73
+ which is the compensation, but it does not yet answer "when was this counting rule met".
74
+ - Reopening a rejected stage does not clear `rejected_at`. Nothing writes that column yet, so there
75
+ is nothing stale to clear; the milestone that starts writing it has to clear it here.
76
+ - `Stage`'s `satisfied` status and `satisfied_at` are unreachable while every cooldown is zero: a
77
+ satisfied stage closes in the same breath. They exist for the window that has not shipped.
@@ -0,0 +1,64 @@
1
+ # ADR-0018: Express eligibility as data, with one implementation of the predicate
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-12
5
+
6
+ ## Context
7
+
8
+ "Who may approve this?" has to be answered in three places that must never disagree: the guard, for
9
+ one loaded request; the inbox query, for every request awaiting a given actor; and the host's own
10
+ policy, where it has one. Expressing eligibility as a block or a lambda answers the first and makes
11
+ the second impossible — a predicate in Ruby cannot be paginated.
12
+
13
+ Hosts also differ in shape. Some have one `User` class and roles; some have `User`, `Admin` and
14
+ `Manager` with different key types and unrelated permission models. A gem that assumes either one is
15
+ useless to the other.
16
+
17
+ ## Decision
18
+
19
+ Eligibility is rows, not code. A quorum carries **permission rows** (`permission` × `actor_type`,
20
+ each independently nullable) and **named-actor rows**, and the two are OR-ed
21
+ ([ADR-0008](0008-staged-multi-quorum-schema.md)).
22
+
23
+ | `actor_type` | `permission` | Means |
24
+ |--------------|--------------|-----------------------------------------|
25
+ | `User` | `editor` | Users holding `editor` |
26
+ | `NULL` | `editor` | anyone holding `editor` |
27
+ | `Admin` | `NULL` | any Admin, whatever their permissions |
28
+ | `NULL` | `NULL` | rejected — constraining nothing is a bug |
29
+
30
+ `permission_match` sits on the **quorum**, not in global configuration: the same rows mean "any of
31
+ these" under `any` and "this actor holds every one of them" under `all`. Identical data, opposite
32
+ meanings, so the mode belongs beside the rows it governs. Configuration supplies only the default.
33
+
34
+ An actor's permissions are read through **their registered type's lambda**, never through a method on
35
+ the actor, so `User` and `Admin` may derive them completely differently and still be compared against
36
+ one quorum definition.
37
+
38
+ `Authorization::Permissions#allows?(actor:, quorum:)` is the one implementation of the predicate, and
39
+ `Guards::Base#qualifying` is its one caller. A host replaces it wholesale by assigning
40
+ `config.authorization`; a bare `->(actor:, request:, stage:, action:)` is coerced into
41
+ `Authorization::Callable` on assignment, so the host writes a lambda and the gem still has an object
42
+ answering `allows?`.
43
+
44
+ ## Consequences
45
+
46
+ ### Positive
47
+
48
+ - The inbox becomes one indexed, paginated query over the same rows the guard reads, rather than a
49
+ scan through Ruby.
50
+ - Heterogeneous actor classes are a first-class case, not a workaround
51
+ ([ADR-0003](0003-actor-references-as-triples.md)).
52
+ - Because the rows are materialised per request and immutable, editing an operation's permission
53
+ list never changes who may approve something already in flight.
54
+
55
+ ### Negative
56
+
57
+ - Four joins to answer "may this person approve?", where a lambda would have been one call.
58
+ - A quorum with no permission rows grants eligibility to nobody by permission — "all of nothing" has
59
+ to be special-cased, or a named-approver quorum would admit everyone under `permission_match: all`.
60
+ - When the inbox query ships, its SQL and this Ruby predicate become two expressions of one rule. The
61
+ mitigation is that there is exactly one Ruby implementation and one shared table of cases for the
62
+ SQL to be held against, not that the risk is absent.
63
+ - An unregistered actor class raises rather than returning false. That is the allowlist working, but
64
+ it means the predicate is not total over arbitrary objects.
@@ -0,0 +1,61 @@
1
+ # ADR-0019: Refuse the requester by identity, and make only execution configurable
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-12
5
+
6
+ ## Context
7
+
8
+ The point of an approval workflow is that somebody other than the requester agrees. A setting that
9
+ turns that off — `requester_may_approve` — is an argument waiting to happen: it implies the gem has
10
+ an opinion that could reasonably be overridden, and it would be set to `true` in exactly the
11
+ installations where four eyes mattered most.
12
+
13
+ Execution is a different question. Running an approved change is not a second opinion, and plenty of
14
+ sound processes have the person who asked for it press the button once others have agreed.
15
+
16
+ Both questions are the same underlying one — is this the same human twice — and that question is not
17
+ answerable from `(type, id)` alone when a host has several actor classes backed by one person.
18
+
19
+ ## Decision
20
+
21
+ **The requester can never approve their own request**, and there is no setting to the contrary: no
22
+ reader, no writer, no constant. `Guards::Approve` compares identity and consults nothing. Naming the
23
+ setting, even to hard-code it to `false`, would imply it were negotiable.
24
+
25
+ **Execution is configurable**, in three independent places:
26
+
27
+ - `t.may_execute` on the registered actor type — whether this *class* of actor may execute at all,
28
+ checked before the separation-of-duties branches so a class declared unable to execute never passes
29
+ a guard a presenter consults.
30
+ - `config.requester_may_execute`, default `false`.
31
+ - `config.approver_may_execute`, default `true`. "An approver" means someone who wrote a row in
32
+ `change_request_approvals` — who actually spent a decision — not merely someone eligible to.
33
+
34
+ **`config.actor_identity` is the opt-in answer to "the same human twice".** When a host supplies it,
35
+ it stands in for `(type, id)` in three places: refusing the requester as an approver, refusing a
36
+ second decision on one stage, and `Execute`'s separation-of-duties branches. All three go through one
37
+ helper, `Guards::Base#same_person?`, so they cannot answer differently.
38
+
39
+ It is deliberately **not** consulted by `Unapprove`. Identity decides who counts as one person when
40
+ tallying decisions; retracting is about which row this actor wrote, and letting one actor delete
41
+ another's row would make the trail say something untrue.
42
+
43
+ ## Consequences
44
+
45
+ ### Positive
46
+
47
+ - The gem's central promise is not a configuration value, so it cannot be turned off by an
48
+ initializer nobody reviewed.
49
+ - Hosts with several actor classes over one identity get the cross-class rules by supplying one
50
+ lambda, and hosts without one lose nothing they had.
51
+ - Separation of duties for execution is expressible without weakening approval.
52
+
53
+ ### Negative
54
+
55
+ - The database's unique index on `(stage, approver_type, approver_id)` cannot express identity, so
56
+ under `config.actor_identity` the guard is deliberately stricter than the constraint behind it. A
57
+ caller bypassing the guard could still write the second row.
58
+ - Three settings govern execution and their interaction is only legible by reading the guard's branch
59
+ order.
60
+ - A host that genuinely wants self-approval — a single-operator installation, say — has no route to
61
+ it and must not use the gem for that workflow.
@@ -0,0 +1,54 @@
1
+ # ADR-0020: Ship refusal reasons as a closed vocabulary that degrades to the symbol
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-12
5
+
6
+ ## Context
7
+
8
+ A refusal has two audiences. Host code needs something stable to branch on; a person needs a
9
+ sentence. Returning a string serves the second badly and the first not at all, and translating in the
10
+ guard puts wording in the domain core — which must keep working in a process where `I18n` was never
11
+ configured ([ADR-0001](0001-headless-domain-core.md)).
12
+
13
+ ## Decision
14
+
15
+ `Guards::Base::REASONS` is the closed vocabulary of refusal symbols, and the symbol is the contract.
16
+ Wording lives in `config/locales/en.yml` and nowhere else.
17
+
18
+ - `Refusal#i18n_key` is `change_requests.errors.<reason>`, falling back to the error class name
19
+ underscored when a caller raised without a reason — so `NotApprovable` has a sentence too.
20
+ - `ChangeRequests::Translation` is the single lookup path. It checks `defined?(I18n)` and every
21
+ caller passes a usable default, so **with no locale file loaded at all the message is the reason
22
+ symbol**. That is asserted in the headless subprocess, where the message for `:requester` is
23
+ literally `"requester"`.
24
+ - The engine needs no code for this: `Rails::Engine` already puts `config/locales` on
25
+ `I18n.load_path`. Creating the directory is the whole of the wiring.
26
+ - Stage and quorum **names** are declaration identifiers in `snake_case`, not display text. They
27
+ resolve through `change_requests.stages.<name>` / `change_requests.quorums.<name>` with
28
+ `name.humanize` as the fallback, so a host that declares `sign_off` reads "Sign off" with no locale
29
+ entry at all.
30
+
31
+ A spec holds the vocabulary and the locale file to each other **in both directions**: every reason
32
+ must have a translation, and every translation must correspond to a reason or to an error class that
33
+ can be raised without one. A vocabulary that only ever grows accumulates symbols nothing raises, and
34
+ a translation for a symbol nothing raises is a promise about behaviour the gem does not have.
35
+
36
+ ## Consequences
37
+
38
+ ### Positive
39
+
40
+ - Hosts branch on symbols and translate independently; overriding one sentence is a locale entry, not
41
+ a monkey patch.
42
+ - The domain core never depends on a locale file existing, so nothing about i18n reaches back into
43
+ the headless guarantee.
44
+ - A new reason cannot ship untranslated, and a dead one cannot linger, because the spec fails either
45
+ way.
46
+
47
+ ### Negative
48
+
49
+ - The reason symbols are public API and have to be versioned as carefully as the class names
50
+ ([ADR-0012](0012-declared-error-taxonomy.md)).
51
+ - The bidirectional spec makes adding a reason a two-file change, deliberately.
52
+ - `I18n.load_path` is process-wide, so assertions about the bare-symbol fallback have to use reasons
53
+ nothing will ever translate; written against real reasons they pass or fail depending on whether
54
+ some other spec file booted Rails first.
@@ -0,0 +1,56 @@
1
+ # ADR-0021: Serialise commands with a row lock and let the unique index arbitrate
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-12
5
+
6
+ ## Context
7
+
8
+ Approvals arrive from people, which means they arrive at the same moment often enough to matter. Two
9
+ races are real in an approval workflow and neither is exotic: two approvers racing the last slot of a
10
+ quorum, and one approver double-clicking.
11
+
12
+ The first must transition the stage exactly once. The second must be refused the way the guard would
13
+ have refused it — a duplicate submission is a user error, and answering it with an exception page
14
+ turns a mis-click into an incident.
15
+
16
+ ## Decision
17
+
18
+ Every command body runs inside `request.with_lock`
19
+ ([ADR-0016](0016-commands-are-the-only-writers.md)), so no two transitions on one request are ever in
20
+ flight together. The lock is taken on the request rather than on the stage: a transition reads the
21
+ stage, its quorums and their links, and decides the request's own status from them.
22
+
23
+ **The lock is not the guarantee, the unique index is.** `(change_request_stage_id, approver_type,
24
+ approver_id)` is what makes one decision per person per stage true even against a caller that never
25
+ took a lock. The lock narrows the window; the constraint closes it. `on_conflict` is what turns the
26
+ database's answer back into the gem's, so the loser hears `NotApprovable(:already_decided)`.
27
+
28
+ The regression specs run real threads on real connections against real PostgreSQL, outside the
29
+ suite's transaction — a second connection cannot see uncommitted rows, so these examples truncate
30
+ instead.
31
+
32
+ **The specs measure serialisation rather than removing the lock.** "Assert the race breaks without
33
+ `with_lock`" is only probabilistically true: the damaging interleaving is likely, not certain, so such
34
+ a spec passes by luck and reddens CI at random. Instead both probes measure the thing the lock exists
35
+ to control — were two command bodies ever inside at the same time? With the lock, never; with it
36
+ removed and the body held open, always. Both directions are deterministic. What the overlap then
37
+ *costs* is deliberately not asserted, because that does depend on the scheduler.
38
+
39
+ ## Consequences
40
+
41
+ ### Positive
42
+
43
+ - Exactly one of two racing approvals closes the stage, and the stage-closing events are written
44
+ once rather than once per thread.
45
+ - A lost race is a domain refusal with a reason, never a `RecordNotUnique` reaching the host.
46
+ - The teeth of the concurrency specs are a measurement that holds under any scheduler, so they can
47
+ run in CI on every commit.
48
+
49
+ ### Negative
50
+
51
+ - A row lock per transition is a serialisation point on the busiest request, and the lock is held
52
+ across the event insert.
53
+ - The concurrency examples cannot use rspec-mocks, which is not thread-safe; the probes are ordinary
54
+ subclasses of the command, which is more code and must be kept in step with it.
55
+ - These examples opt out of transactional fixtures and clean up by truncation, so they are the one
56
+ group in the suite whose isolation is different from every other.
data/docs/adr/README.md CHANGED
@@ -5,7 +5,10 @@ One record per significant architectural choice: its context, the decision, and
5
5
  These describe the gem **as it stands today**. Decisions still ahead of the implementation live in
6
6
  `PLAN.md` until the code that makes them real exists.
7
7
 
8
- A record is never edited once accepted. It is superseded by a later one, which links back to it.
8
+ A record is never rewritten once accepted: a decision that is reversed is **superseded** by a later
9
+ record, which links back to it. A record may still be **corrected** where it describes the code
10
+ inaccurately — these describe the gem as it stands, and a wrong statement left in place is worse
11
+ than an edit.
9
12
 
10
13
  | ADR | Title | Status |
11
14
  |-----|-------|--------|
@@ -23,3 +26,10 @@ A record is never edited once accepted. It is superseded by a later one, which l
23
26
  | [0012](0012-declared-error-taxonomy.md) | Declare the whole error taxonomy before raising any of it | Accepted |
24
27
  | [0013](0013-json-runtime-pin.md) | Pin `json` to `~> 2.7` as a runtime dependency | Accepted |
25
28
  | [0014](0014-executable-architecture-rules.md) | Enforce the architectural boundaries executably | Accepted |
29
+ | [0015](0015-one-guard-object-per-transition.md) | Consult one guard object for every transition | Accepted |
30
+ | [0016](0016-commands-are-the-only-writers.md) | Make commands the only writers, under one lock and one event path | Accepted |
31
+ | [0017](0017-approvals-count-through-links.md) | Count approvals only through the links written at decision time | Accepted |
32
+ | [0018](0018-eligibility-is-data.md) | Express eligibility as data, with one implementation of the predicate | Accepted |
33
+ | [0019](0019-separation-of-duties.md) | Refuse the requester by identity, and make only execution configurable | Accepted |
34
+ | [0020](0020-refusal-vocabulary-and-fallback.md) | Ship refusal reasons as a closed vocabulary that degrades to the symbol | Accepted |
35
+ | [0021](0021-serialise-commands-let-the-index-arbitrate.md) | Serialise commands with a row lock and let the unique index arbitrate | Accepted |
@@ -23,8 +23,8 @@ module ChangeRequests
23
23
  approval = record_decision
24
24
  link(approval, guard.countable_quorums)
25
25
  emit(:approved, body: comment, metadata: metadata_for(approval))
26
+ EvaluateWorkflow.call(request: request)
26
27
 
27
- # Commands::EvaluateWorkflow is M1b-12. Until it lands nothing advances the stage.
28
28
  request
29
29
  end
30
30
 
@@ -93,9 +93,9 @@ module ChangeRequests
93
93
  # Unregistered classes are the allowlist's business, and actor_attributes raises for them.
94
94
  return if type.nil? || type.may_request
95
95
 
96
- fail NotAuthorized,
97
- "#{requester.class.name} may not raise change requests. " \
98
- "Set `t.may_request = true` on its registration (§9.1)."
96
+ # No message: the reason carries it, so the text comes from the host's locale file and says
97
+ # nothing about `t.may_request` - a configuration key has no business in a flash (Q45).
98
+ fail NotAuthorized.new(reason: :may_not_request)
99
99
  end
100
100
 
101
101
  def write_request(declaration)
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ module Commands
5
+ # The only code that changes stage or request status as a consequence of a decision (§7.1).
6
+ #
7
+ # A command like the others - guard, mutate, emit - but an **internal** one: it takes no actor,
8
+ # hosts never call it, and it is invoked only from `Approve`, `Unapprove` and `Reject`, inside
9
+ # the lock they already hold.
10
+ #
11
+ # Its events carry the System sentinel, and that is deliberate: closing a stage is the gem's own
12
+ # act, not the approver's. The approvals that caused it are already in the trail, each with its
13
+ # own actor, so "System closed the stage" sits directly beneath the rows naming everyone who
14
+ # approved (§19.15, Q36).
15
+ class EvaluateWorkflow < Base
16
+ def self.call(request:)
17
+ new(request: request).call
18
+ end
19
+
20
+ def perform
21
+ return request if stage.nil?
22
+ return request if settle_rejection == :stopped
23
+
24
+ satisfy_quorums
25
+ close_stage! if satisfied?
26
+
27
+ request
28
+ end
29
+
30
+ private
31
+
32
+ # No lock of its own. The caller holds one, and re-locking would reload the request they just
33
+ # wrote to - `with_lock` reloads under SELECT … FOR UPDATE. The error mapping in
34
+ # Commands::Base#call still applies.
35
+ def around_perform
36
+ yield
37
+ end
38
+
39
+ def stage
40
+ @stage ||= request.current_stage
41
+ end
42
+
43
+ # Step 0, before any recount: a rejection outranks any number of approvals. A stage holding one
44
+ # is `rejected` and cannot be satisfied; a stage that was rejected and holds none any more goes
45
+ # back to `pending`, and the approvals it already collected are still there to count (§7.1).
46
+ #
47
+ # At cooldown 0 - all of M1 - `Commands::Reject` writes the stage and the request rejection in
48
+ # one lock, so a `pending` request on a rejected stage never exists through the public API.
49
+ # M9b's window is what makes this reachable; it is correct and cheap at every cooldown value,
50
+ # so it ships here rather than being bolted on later (Q35).
51
+ def settle_rejection
52
+ if stopping_rejection?
53
+ # M9b owns rejected_at, together with CloseStageJob and the window it measures.
54
+ stage.update!(status: "rejected") unless stage.rejected?
55
+
56
+ return :stopped
57
+ end
58
+
59
+ stage.update!(status: "pending") if stage.rejected?
60
+
61
+ :unchanged
62
+ end
63
+
64
+ # Under only_record_rejections a rejection stops nothing: the decision and the event are
65
+ # recorded, the stage stays open, and everyone else's approvals still count (§7.1). Asking the
66
+ # config here rather than skipping step 0 outright means a stage rejected before the flag was
67
+ # turned on still reopens.
68
+ def stopping_rejection?
69
+ return false if config.only_record_rejections
70
+
71
+ stage.approvals.exists?(decision: "rejected")
72
+ end
73
+
74
+ # Counting is only via change_request_approval_quorums, written at decision time and never
75
+ # re-derived: a later role change must not silently un-approve a request (§5.3).
76
+ def satisfy_quorums
77
+ stage.quorums.each do |quorum|
78
+ met = quorum.approval_quorums.count >= quorum.threshold
79
+
80
+ next if met == quorum.satisfied?
81
+
82
+ quorum.update!(status: met ? "satisfied" : "pending", satisfied_at: met ? Time.current : nil)
83
+ end
84
+ end
85
+
86
+ # any_quorum: at least one. all_quorums: every one - the difference between "one Admin OR two
87
+ # Owners" and "one Admin AND two Owners" (§5.3). M9a adds the *linking* rule that stops one
88
+ # person closing two quorums of an all_quorums stage; this is only the counting rule.
89
+ def satisfied?
90
+ quorums = stage.quorums.reload
91
+
92
+ return false if quorums.empty?
93
+
94
+ stage.all_quorums? ? quorums.all?(&:satisfied?) : quorums.any?(&:satisfied?)
95
+ end
96
+
97
+ # §7.1's four writes. Cooldown is M9b, so the window is always zero here and a satisfied stage
98
+ # closes in the same breath.
99
+ def close_stage!
100
+ stage.quorums.select(&:satisfied?).each do |quorum|
101
+ emit(:quorum_satisfied, metadata: quorum_metadata(quorum))
102
+ end
103
+
104
+ stage.update!(status: "closed", closed_at: Time.current)
105
+ emit(:stage_satisfied, metadata: quorum_metadata(closing_quorum))
106
+
107
+ advance
108
+ end
109
+
110
+ # The quorum that closed it (§7.1). With one nameless quorum per stage there is no name to
111
+ # give, so the key is omitted rather than emitted as null, as Commands::Approve does (§5.9).
112
+ def quorum_metadata(quorum)
113
+ metadata = { stage: stage.name }
114
+ metadata[:quorum] = quorum.name if quorum&.name.present?
115
+
116
+ metadata
117
+ end
118
+
119
+ def closing_quorum
120
+ stage.quorums.select(&:satisfied?).min_by(&:position)
121
+ end
122
+
123
+ # The sequential advance ships now (D5): deferring it would mean rewriting this command in
124
+ # M9a rather than extending it.
125
+ def advance
126
+ following = request.stages.find_by(position: stage.position + 1)
127
+
128
+ return request.update!(status: "approved") if following.nil?
129
+
130
+ request.update!(current_stage_position: following.position)
131
+ end
132
+ end
133
+ end
134
+ end
@@ -26,9 +26,15 @@ module ChangeRequests
26
26
  record_decision
27
27
  emit(:rejected, body: reason, metadata: metadata)
28
28
 
29
- stop unless config.only_record_rejections
29
+ if config.only_record_rejections
30
+ # The workflow continues, so the stage may still be satisfied by everyone else (§7.1).
31
+ EvaluateWorkflow.call(request: request)
32
+ else
33
+ # No evaluation after `stop`: the request is already `rejected` and final, and re-entering
34
+ # evaluation on it is at best a wasted query.
35
+ stop
36
+ end
30
37
 
31
- # Commands::EvaluateWorkflow is M1b-12, and only the recorded-only branch would call it.
32
38
  request
33
39
  end
34
40
 
@@ -27,8 +27,10 @@ module ChangeRequests
27
27
  decision.destroy!
28
28
 
29
29
  emit(:unapproved, metadata: metadata)
30
+ # A quorum that lost its threshold goes back to pending, and a stage whose last rejection
31
+ # this was reopens (§7.1).
32
+ EvaluateWorkflow.call(request: request)
30
33
 
31
- # Commands::EvaluateWorkflow is M1b-12. Until it lands nothing re-counts the stage.
32
34
  request
33
35
  end
34
36
 
@@ -10,6 +10,10 @@ module ChangeRequests
10
10
  refuses_with NotApprovable
11
11
 
12
12
  def refusal
13
+ # A finished request is the same refusal whichever command met it, and Q29 maps this one
14
+ # reason to AlreadyFinalized for every guard. `:not_pending` then means what it says:
15
+ # open, but not open for decisions (Q48).
16
+ return :already_finalized if request.final?
13
17
  return :not_pending unless request.pending?
14
18
  # Never configurable. There is no config.requester_may_approve to read, in any form (I5).
15
19
  return :requester if same_person?(request.requester, actor)
@@ -14,6 +14,10 @@ module ChangeRequests
14
14
  refuses_with NotRejectable
15
15
 
16
16
  def refusal
17
+ # A finished request is the same refusal whichever command met it, and Q29 maps this one
18
+ # reason to AlreadyFinalized for every guard. `:not_pending` then means what it says:
19
+ # open, but not open for decisions (Q48).
20
+ return :already_finalized if request.final?
17
21
  return :not_pending unless request.pending?
18
22
  return :stage_not_current if !permitted_here? && eligible_on_another_stage?
19
23
  return :already_decided if already_decided?
@@ -10,6 +10,10 @@ module ChangeRequests
10
10
  refuses_with NotUnapprovable
11
11
 
12
12
  def refusal
13
+ # A finished request is the same refusal whichever command met it, and Q29 maps this one
14
+ # reason to AlreadyFinalized for every guard. `:not_pending` then means what it says:
15
+ # open, but not open for decisions (Q48).
16
+ return :already_finalized if request.final?
13
17
  return :not_pending unless request.pending?
14
18
  return :not_the_approver if decision.nil?
15
19
  return :stage_not_open unless decision.stage.pending?
@@ -8,8 +8,9 @@ module ChangeRequests
8
8
  # string_enum :status, %w(pending satisfied closed)
9
9
  # Stage.statuses #=> [...] Stage.pending #=> scope stage.pending? #=> true
10
10
  #
11
- # Not Rails' enum: it owns the reader and writer, maps through a hash, and raises at assignment
12
- # instead of reporting a validation error.
11
+ # Not Rails' enum: no generated writer, no mapping hash, no dangerous-name collision check, and
12
+ # no class-level constant. An out-of-range value is assigned as given and reported by the
13
+ # inclusion validation; the CHECK constraint is the floor beneath that.
13
14
  module StringEnum
14
15
  extend ActiveSupport::Concern
15
16
 
@@ -12,13 +12,15 @@ module ChangeRequests
12
12
 
13
13
  KINDS = %w(
14
14
  requested approved unapproved rejected commented canceled
15
- quorum_satisfied stage_satisfied stage_closed overridden
15
+ quorum_satisfied stage_satisfied overridden
16
16
  execution_started executed execution_failed
17
17
  expired reaped operation_undeclared
18
18
  ).freeze
19
19
 
20
20
  # Inclusion only, no CHECK: every later milestone adds kinds, and a CHECK would make each one a
21
- # migration in every host application (§19.16).
21
+ # migration in every host application (§19.16) - which is also why `stage_closed` could simply be
22
+ # dropped when it turned out nothing emitted it. Cooldown (M9b) puts a window between
23
+ # satisfaction and closing, and brings it back if it needs one (Q41).
22
24
  string_enum :kind, KINDS
23
25
 
24
26
  belongs_to :change_request, class_name: "ChangeRequests::Request", inverse_of: :events
@@ -3,8 +3,8 @@
3
3
  module ChangeRequests
4
4
  # Eligibility by permission × actor type, both independently nullable (§5.3):
5
5
  #
6
- # ("User", "editor") Users holding :editor (NULL, "editor") anyone holding :editor
7
- # ("Admin", NULL) any Admin (NULL, NULL) rejected
6
+ # ("editor", "User") Users holding :editor ("editor", NULL) anyone holding :editor
7
+ # (NULL, "Admin") any Admin (NULL, NULL) rejected
8
8
  #
9
9
  # Write-once: materialised from the frozen workflow when the request is created, so editing an
10
10
  # operation's permission list never changes who may approve a request already in flight.
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChangeRequests
4
+ # The minimum needed to write a concurrency spec (§15.3). The host-facing test kit is M8.
5
+ module Testing
6
+ module_function
7
+
8
+ # Runs the block on `count` real threads, each with its own connection, released together.
9
+ #
10
+ # results = Testing.in_parallel(2) { |i| Commands::Approve.call(request:, actor: actors[i]) }
11
+ #
12
+ # Returns one entry per thread, in order: the block's value, or the exception it raised.
13
+ # Raising is an outcome here rather than a failure - "the loser of this race is told X" is the
14
+ # whole claim - so nothing is re-raised and the caller decides what the pair should be.
15
+ #
16
+ # Threads start blocked on a barrier and are released at once, which is what makes them race
17
+ # rather than run one after another. `connection_pool.with_connection` gives each its own
18
+ # connection and returns it afterwards, so the pool is not drained by a spec that fails.
19
+ def in_parallel(count)
20
+ gate = Barrier.new(count)
21
+
22
+ threads = Array.new(count) do |index|
23
+ Thread.new do
24
+ ActiveRecord::Base.connection_pool.with_connection do
25
+ gate.wait
26
+ begin
27
+ yield(index)
28
+ rescue StandardError => e
29
+ e
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ threads.map(&:value)
36
+ end
37
+
38
+ # Everyone waits until the last one arrives, then all proceed. Ruby ships no barrier and
39
+ # concurrent-ruby is not a dependency of this gem.
40
+ class Barrier
41
+ def initialize(count)
42
+ @count = count
43
+ @mutex = Mutex.new
44
+ @condition = ConditionVariable.new
45
+ @arrived = 0
46
+ end
47
+
48
+ def wait
49
+ @mutex.synchronize do
50
+ @arrived += 1
51
+
52
+ next @condition.broadcast if @arrived >= @count
53
+
54
+ @condition.wait(@mutex) while @arrived < @count
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChangeRequests
4
- VERSION = "0.2.2"
4
+ VERSION = "0.2.5"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: change_requests
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andreas Finger
@@ -308,6 +308,7 @@ files:
308
308
  - LICENSE.txt
309
309
  - README.md
310
310
  - Rakefile
311
+ - config/locales/en.yml
311
312
  - config/routes.rb
312
313
  - docs/08_events_and_notifications.md
313
314
  - docs/adr/0001-headless-domain-core.md
@@ -324,6 +325,13 @@ files:
324
325
  - docs/adr/0012-declared-error-taxonomy.md
325
326
  - docs/adr/0013-json-runtime-pin.md
326
327
  - docs/adr/0014-executable-architecture-rules.md
328
+ - docs/adr/0015-one-guard-object-per-transition.md
329
+ - docs/adr/0016-commands-are-the-only-writers.md
330
+ - docs/adr/0017-approvals-count-through-links.md
331
+ - docs/adr/0018-eligibility-is-data.md
332
+ - docs/adr/0019-separation-of-duties.md
333
+ - docs/adr/0020-refusal-vocabulary-and-fallback.md
334
+ - docs/adr/0021-serialise-commands-let-the-index-arbitrate.md
327
335
  - docs/adr/README.md
328
336
  - lib/change_requests.rb
329
337
  - lib/change_requests/authorization/callable.rb
@@ -333,6 +341,7 @@ files:
333
341
  - lib/change_requests/commands/cancel.rb
334
342
  - lib/change_requests/commands/comment.rb
335
343
  - lib/change_requests/commands/create.rb
344
+ - lib/change_requests/commands/evaluate_workflow.rb
336
345
  - lib/change_requests/commands/expire.rb
337
346
  - lib/change_requests/commands/reject.rb
338
347
  - lib/change_requests/commands/unapprove.rb
@@ -370,6 +379,7 @@ files:
370
379
  - lib/change_requests/operation.rb
371
380
  - lib/change_requests/operations.rb
372
381
  - lib/change_requests/presenters/.keep
382
+ - lib/change_requests/testing.rb
373
383
  - lib/change_requests/translation.rb
374
384
  - lib/change_requests/version.rb
375
385
  - lib/change_requests/workflow.rb