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 +4 -4
- data/config/locales/en.yml +55 -0
- data/docs/adr/0011-system-sentinel-actor.md +3 -1
- data/docs/adr/0012-declared-error-taxonomy.md +7 -1
- data/docs/adr/0015-one-guard-object-per-transition.md +60 -0
- data/docs/adr/0016-commands-are-the-only-writers.md +66 -0
- data/docs/adr/0017-approvals-count-through-links.md +77 -0
- data/docs/adr/0018-eligibility-is-data.md +64 -0
- data/docs/adr/0019-separation-of-duties.md +61 -0
- data/docs/adr/0020-refusal-vocabulary-and-fallback.md +54 -0
- data/docs/adr/0021-serialise-commands-let-the-index-arbitrate.md +56 -0
- data/docs/adr/README.md +11 -1
- data/lib/change_requests/commands/approve.rb +1 -1
- data/lib/change_requests/commands/create.rb +3 -3
- data/lib/change_requests/commands/evaluate_workflow.rb +134 -0
- data/lib/change_requests/commands/reject.rb +8 -2
- data/lib/change_requests/commands/unapprove.rb +3 -1
- data/lib/change_requests/guards/approve.rb +4 -0
- data/lib/change_requests/guards/reject.rb +4 -0
- data/lib/change_requests/guards/unapprove.rb +4 -0
- data/lib/change_requests/models/concerns/string_enum.rb +3 -2
- data/lib/change_requests/models/event.rb +4 -2
- data/lib/change_requests/models/quorum_permission.rb +2 -2
- data/lib/change_requests/testing.rb +59 -0
- data/lib/change_requests/version.rb +1 -1
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7790d14f78ffa0b667712d827db6cc77d527dd9f733582b95b0a43ca4372d156
|
|
4
|
+
data.tar.gz: 21b70a37fe29a796e855571f82901d1e098328ec5b0d567335c5982b0622ccce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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:
|
|
12
|
-
#
|
|
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
|
|
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
|
-
# ("
|
|
7
|
-
# ("Admin"
|
|
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
|
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.
|
|
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
|