change_requests 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +5 -0
- data/docs/adr/0001-headless-domain-core.md +40 -0
- data/docs/adr/0002-mountable-engine-with-isolated-namespace.md +38 -0
- data/docs/adr/0003-actor-references-as-triples.md +43 -0
- data/docs/adr/0004-uuid-primary-keys.md +35 -0
- data/docs/adr/0005-string-states-with-check-constraints.md +43 -0
- data/docs/adr/0006-creation-time-immutability.md +39 -0
- data/docs/adr/0007-append-only-audit-trail.md +42 -0
- data/docs/adr/0008-staged-multi-quorum-schema.md +43 -0
- data/docs/adr/0009-host-owned-schema.md +43 -0
- data/docs/adr/0010-operations-must-be-declared.md +57 -0
- data/docs/adr/0011-system-sentinel-actor.md +36 -0
- data/docs/adr/0012-declared-error-taxonomy.md +40 -0
- data/docs/adr/0013-json-runtime-pin.md +37 -0
- data/docs/adr/0014-executable-architecture-rules.md +45 -0
- data/docs/adr/README.md +25 -0
- data/lib/change_requests/authorization/callable.rb +30 -0
- data/lib/change_requests/authorization/permissions.rb +66 -1
- data/lib/change_requests/commands/approve.rb +69 -0
- data/lib/change_requests/commands/base.rb +113 -0
- data/lib/change_requests/commands/cancel.rb +43 -0
- data/lib/change_requests/commands/comment.rb +40 -0
- data/lib/change_requests/commands/create.rb +169 -0
- data/lib/change_requests/commands/expire.rb +27 -0
- data/lib/change_requests/commands/reject.rb +77 -0
- data/lib/change_requests/commands/unapprove.rb +49 -0
- data/lib/change_requests/configuration.rb +22 -1
- data/lib/change_requests/errors.rb +45 -26
- data/lib/change_requests/guards/approve.rb +31 -0
- data/lib/change_requests/guards/base.rb +201 -0
- data/lib/change_requests/guards/cancel.rb +32 -0
- data/lib/change_requests/guards/comment.rb +32 -0
- data/lib/change_requests/guards/execute.rb +48 -0
- data/lib/change_requests/guards/expire.rb +37 -0
- data/lib/change_requests/guards/reject.rb +38 -0
- data/lib/change_requests/guards/unapprove.rb +41 -0
- data/lib/change_requests/models/approval.rb +1 -1
- data/lib/change_requests/models/attempt.rb +3 -1
- data/lib/change_requests/models/concerns/actor_columns.rb +12 -6
- data/lib/change_requests/models/event.rb +3 -1
- data/lib/change_requests/models/quorum.rb +7 -2
- data/lib/change_requests/models/request.rb +11 -2
- data/lib/change_requests/models/stage.rb +10 -3
- data/lib/change_requests/operation.rb +6 -2
- data/lib/change_requests/operations.rb +14 -4
- data/lib/change_requests/translation.rb +3 -1
- data/lib/change_requests/version.rb +1 -1
- data/lib/change_requests/workflow.rb +3 -1
- data/lib/change_requests.rb +9 -3
- data/lib/generators/change_requests/install/templates/migration.rb.tt +5 -0
- metadata +33 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 205a020929c71027848bc891897a26676a96fbc2c77a6d2e32aa5cedc577109c
|
|
4
|
+
data.tar.gz: 172b2e90ff436bd10e42e5a30eb18052b65b20bf660156efec238b58aa58abf5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a6b83acddf5fc81320a9cb370e2212b5ae173c2a73e838247082ac97222cf404b56a446f37819d9b681f0c4ba1a05280d81dd1e164a0a35d650fd9f0dd1f9ed3
|
|
7
|
+
data.tar.gz: '0013798bab6a0a057b10a9100fb751cbf85589a31502284b79e1612141b341494bd5e12b6a978b56be3ba6f2842ecf06b203abc033965ed32d62581575858dbe'
|
data/README.md
CHANGED
|
@@ -28,6 +28,11 @@ gem install change_requests
|
|
|
28
28
|
|
|
29
29
|
TODO: Write usage instructions here
|
|
30
30
|
|
|
31
|
+
## Architecture
|
|
32
|
+
|
|
33
|
+
The decisions behind the gem's shape — and what each one costs — are recorded as ADRs in
|
|
34
|
+
[docs/adr/](docs/adr/README.md).
|
|
35
|
+
|
|
31
36
|
## Development
|
|
32
37
|
|
|
33
38
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# ADR-0001: Keep the domain core headless
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Approval workflows are triggered from more places than a controller: a rake task, a console session,
|
|
9
|
+
a background job, an API client. A gem whose domain logic can only load inside a booted Rails
|
|
10
|
+
application forces every one of those callers through a framework it does not otherwise need, and
|
|
11
|
+
makes the gem's own test suite slower and less honest than it should be.
|
|
12
|
+
|
|
13
|
+
ActiveRecord is unavoidable — the gem owns nine tables. The rest of Rails is not.
|
|
14
|
+
|
|
15
|
+
## Decision
|
|
16
|
+
|
|
17
|
+
Everything under `lib/change_requests/` except `engine.rb` is domain code and loads with `Rails`
|
|
18
|
+
undefined. The entrypoint requires only `active_record` and `zeitwerk`, then calls
|
|
19
|
+
`ChangeRequests.load_engine!`, which returns early unless `::Rails::Engine` is already defined.
|
|
20
|
+
Loading the Rails layer is therefore opt-in and driven by the host's own boot order.
|
|
21
|
+
|
|
22
|
+
Because `require` fires once, `load_engine!` is public and idempotent: a host that requires this gem
|
|
23
|
+
before Rails exists can call it again afterwards.
|
|
24
|
+
|
|
25
|
+
## Consequences
|
|
26
|
+
|
|
27
|
+
### Positive
|
|
28
|
+
|
|
29
|
+
- The domain runs from any process that can open a database connection.
|
|
30
|
+
- No domain file may name `Rails`, `ActionController`, `ActionView`, `ActionDispatch` or
|
|
31
|
+
`ActiveJob`; see [ADR-0014](0014-executable-architecture-rules.md) for how that is enforced.
|
|
32
|
+
- The headless path is exercised for real: an integration spec migrates the schema and builds a
|
|
33
|
+
request graph in a subprocess that never requires Rails.
|
|
34
|
+
|
|
35
|
+
### Negative
|
|
36
|
+
|
|
37
|
+
- Conveniences that are free inside Rails — `I18n`, time zones, ActiveJob — must be reached
|
|
38
|
+
defensively or declared optional. `ChangeRequests::Translation` exists only for that reason.
|
|
39
|
+
- The two Rails-facing references the entrypoint is allowed are `defined?`-guarded and must stay
|
|
40
|
+
that way, which is easy to regress and needs an explicit rule to prevent.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# ADR-0002: Ship the Rails layer as a mountable engine with an isolated namespace
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The gem ships models, and will ship routes, controllers and views. Dropped into a host application
|
|
9
|
+
unnamespaced, those would collide with the host's own `Request`, `Approval` or `Event`, and the
|
|
10
|
+
gem's routes would appear at paths the host did not choose.
|
|
11
|
+
|
|
12
|
+
## Decision
|
|
13
|
+
|
|
14
|
+
`ChangeRequests::Engine` is a mountable `Rails::Engine` calling `isolate_namespace ChangeRequests`.
|
|
15
|
+
It is the only file in the gem that touches Rails, and Zeitwerk ignores it so that an eager load in
|
|
16
|
+
a headless process cannot pull Rails in ([ADR-0001](0001-headless-domain-core.md)).
|
|
17
|
+
|
|
18
|
+
`ChangeRequests.table_name_prefix` is defined on the module **before** `engine.rb` can load.
|
|
19
|
+
`isolate_namespace` installs its own prefix only `unless mod.respond_to?(:table_name_prefix)`, and
|
|
20
|
+
its version yields `change_requests_stages`; ours yields `change_request_stages`.
|
|
21
|
+
|
|
22
|
+
The engine validates the host's configuration in `config.after_initialize`, so a misconfiguration
|
|
23
|
+
fails the boot rather than the first request that touches the gem.
|
|
24
|
+
|
|
25
|
+
## Consequences
|
|
26
|
+
|
|
27
|
+
### Positive
|
|
28
|
+
|
|
29
|
+
- No constant, table name or route leaks into the host.
|
|
30
|
+
- Every table name is derived from one prefix plus the model name. Only `Request` sets a table name
|
|
31
|
+
explicitly, because the convention would give it `change_request_requests`.
|
|
32
|
+
- Configuration errors surface at deploy time, listed all at once.
|
|
33
|
+
|
|
34
|
+
### Negative
|
|
35
|
+
|
|
36
|
+
- The prefix must stay defined ahead of the engine. It is load-order-sensitive and silently wrong if
|
|
37
|
+
reordered, so a spec asserts it against a real engine boot.
|
|
38
|
+
- The host must mount the engine to get any of the Rails-facing features.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# ADR-0003: Reference host actors by a (type, id, label) triple
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The gem records who requested, who approved and who executed, but knows nothing about the host's
|
|
9
|
+
user model. Hosts have several actor classes at once (`User`, `Admin`, `Accounts::Manager`), key
|
|
10
|
+
them with integers, uuids or strings, and delete them. A foreign key into a host table would force
|
|
11
|
+
one class, one key type, and would take the audit trail down with the record.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
Every actor reference is three columns: `*_type`, `*_id` and, where a decision was recorded,
|
|
16
|
+
`*_label`.
|
|
17
|
+
|
|
18
|
+
- `*_id` is a **string** column, so heterogeneous primary key types share it.
|
|
19
|
+
- `*_type` is validated against `ChangeRequests.config.actor_types`, a registry the host declares.
|
|
20
|
+
The registry is the allowlist: a stored type string is compared against it and is never
|
|
21
|
+
`constantize`d to decide whether it is acceptable.
|
|
22
|
+
- `*_label` is **snapshotted at write time** through the lambda the registration supplies, and is
|
|
23
|
+
never recomputed.
|
|
24
|
+
|
|
25
|
+
No foreign key in the gem points at a host table, and none ever will.
|
|
26
|
+
|
|
27
|
+
## Consequences
|
|
28
|
+
|
|
29
|
+
### Positive
|
|
30
|
+
|
|
31
|
+
- A request stays readable after the actor record is deleted or renamed.
|
|
32
|
+
- A typo in a type string fails at creation, not at render time.
|
|
33
|
+
- Hosts mix actor classes and key types freely. A registration also declares the class's `key_type`,
|
|
34
|
+
so a reader can cast the string back to the host's own key.
|
|
35
|
+
|
|
36
|
+
### Negative
|
|
37
|
+
|
|
38
|
+
- The stored label is a snapshot and drifts from the record. `config.actor_label_strategy` exists so
|
|
39
|
+
that readers can prefer the live record and fall back to the snapshot.
|
|
40
|
+
- Nothing at the database level guarantees an actor still exists; referential integrity here is a
|
|
41
|
+
deliberate non-goal.
|
|
42
|
+
- Every actor class must be registered before it can request or approve, which is one more thing a
|
|
43
|
+
host has to remember.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# ADR-0004: Use uuid primary keys for every gem-owned table
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The gem creates nine tables in the host's database. An earlier draft let the host choose between
|
|
9
|
+
uuid and bigint at install time, via a `--primary-key-type` flag on the install generator. That
|
|
10
|
+
choice doubles the schema surface under test, has to be threaded through every foreign key in the
|
|
11
|
+
migration template, and was never actually asked for.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
Every gem-owned primary key and every foreign key between gem-owned tables is a `uuid`. There is no
|
|
16
|
+
install-time flag and no bigint variant.
|
|
17
|
+
|
|
18
|
+
This says nothing about the host's own tables. `config.actor_type … t.key_type` is a different
|
|
19
|
+
setting, describing how a host record's key is cast into the gem's string `*_id` column
|
|
20
|
+
([ADR-0003](0003-actor-references-as-triples.md)).
|
|
21
|
+
|
|
22
|
+
## Consequences
|
|
23
|
+
|
|
24
|
+
### Positive
|
|
25
|
+
|
|
26
|
+
- One schema shape to test, document and migrate.
|
|
27
|
+
- A request id is safe to put in a URL, and is what execution hands to targets declaring a
|
|
28
|
+
`change_request_id:` keyword, so no separate idempotency key column is needed.
|
|
29
|
+
|
|
30
|
+
### Negative
|
|
31
|
+
|
|
32
|
+
- Requires `gen_random_uuid()`. PostgreSQL 13 and later provide it natively; an older host has to
|
|
33
|
+
enable `pgcrypto` itself, because the migration does not enable extensions in the host's database.
|
|
34
|
+
- Wider indexes, and no natural insertion order — `created_at` is the ordering column everywhere.
|
|
35
|
+
- A host that keys everything else with bigints gets a mixed convention it did not choose.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# ADR-0005: Store states as strings with CHECK constraints, not PostgreSQL enums
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Seven columns hold a closed set of values: a request's `status`, a stage's `status` and
|
|
9
|
+
`satisfied_by`, a quorum's `status` and `permission_match`, an approval's `decision` and an
|
|
10
|
+
attempt's `outcome`. The obvious database-native answer is a PostgreSQL enum type.
|
|
11
|
+
|
|
12
|
+
The gem is an engine, so its schema lives in other people's applications. A milestone that adds one
|
|
13
|
+
state to one of those sets would then require an `ALTER TYPE` migration in every host that installed
|
|
14
|
+
the gem.
|
|
15
|
+
|
|
16
|
+
## Decision
|
|
17
|
+
|
|
18
|
+
Those columns are `string`, constrained twice:
|
|
19
|
+
|
|
20
|
+
- a `CHECK` constraint in the migration, so the database refuses an unknown value;
|
|
21
|
+
- a `Concerns::StringEnum` declaration in the model, which adds an inclusion validation, a scope and
|
|
22
|
+
a predicate per value.
|
|
23
|
+
|
|
24
|
+
`StringEnum` is not Rails' `enum`: it leaves the column's own reader and writer alone, maps nothing
|
|
25
|
+
through a hash, and reports an unknown value as a validation error rather than raising at
|
|
26
|
+
assignment.
|
|
27
|
+
|
|
28
|
+
`kind` on the event table is deliberately *not* in this list. It is a validation only, because later
|
|
29
|
+
milestones add kinds and a CHECK would make each one a migration in every host application.
|
|
30
|
+
|
|
31
|
+
## Consequences
|
|
32
|
+
|
|
33
|
+
### Positive
|
|
34
|
+
|
|
35
|
+
- Adding a state is a model change plus one CHECK migration, not a type change coordinated across
|
|
36
|
+
every installation.
|
|
37
|
+
- Values read as themselves in `psql`, in logs and in the audit trail.
|
|
38
|
+
- The set is enforced at both layers, so a direct `INSERT` cannot write an unknown state.
|
|
39
|
+
|
|
40
|
+
### Negative
|
|
41
|
+
|
|
42
|
+
- Wider columns than an enum's four bytes, and the set is declared in two places that must agree.
|
|
43
|
+
- Nothing stops a host from dropping a CHECK constraint and writing whatever it likes.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# ADR-0006: Enforce creation-time immutability in the gem, not with `attr_readonly`
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Twelve columns on `change_requests` are creation-time facts: what will be invoked, who asked, and
|
|
9
|
+
the labels snapshotted so the row stays readable once the actor and the payload's records are gone.
|
|
10
|
+
Changing any of them after the fact rewrites what a request *was*, which is the one thing an
|
|
11
|
+
approval gate exists to prevent.
|
|
12
|
+
|
|
13
|
+
Rails' `attr_readonly` **silently discards** the assignment unless the host application has
|
|
14
|
+
`config.active_record.raise_on_assign_to_attr_readonly` enabled — a setting an engine cannot
|
|
15
|
+
control, and silence is exactly the behaviour this column list exists to prevent.
|
|
16
|
+
|
|
17
|
+
## Decision
|
|
18
|
+
|
|
19
|
+
`Concerns::ReadonlyAttributes` declares the protected columns with `readonly_after_create` and
|
|
20
|
+
installs a `before_update` guard that raises `ChangeRequests::ReadonlyAttribute`, naming every
|
|
21
|
+
changed attribute at once.
|
|
22
|
+
|
|
23
|
+
`update_columns` bypasses it, by design: it bypasses callbacks everywhere in Rails, and a method
|
|
24
|
+
whose entire purpose is to skip the model layer should keep doing so.
|
|
25
|
+
|
|
26
|
+
## Consequences
|
|
27
|
+
|
|
28
|
+
### Positive
|
|
29
|
+
|
|
30
|
+
- The refusal is loud and identical in every host, whatever the host's ActiveRecord settings.
|
|
31
|
+
- The error is part of the gem's own taxonomy ([ADR-0012](0012-declared-error-taxonomy.md)), so a
|
|
32
|
+
host rescues one hierarchy rather than two.
|
|
33
|
+
- It covers `jsonb` columns, which change by mutation as readily as by assignment.
|
|
34
|
+
|
|
35
|
+
### Negative
|
|
36
|
+
|
|
37
|
+
- One more `before_update` callback on the hot path of every save.
|
|
38
|
+
- Protection is application-level only. `update_columns`, raw SQL and a console session all go
|
|
39
|
+
around it; a host wanting more has to add database rules itself.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# ADR-0007: Make the audit trail and the eligibility rows append-only
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
`change_request_events` is the record of what happened and who did it. Its value depends entirely on
|
|
9
|
+
nobody being able to revise it afterwards.
|
|
10
|
+
|
|
11
|
+
The eligibility rows — `change_request_quorum_permissions`,
|
|
12
|
+
`change_request_quorum_eligible_actors`, `change_request_approval_quorums` — need the same
|
|
13
|
+
protection for a different reason. They are materialised from the operation's workflow when the
|
|
14
|
+
request is created, and they are the frozen snapshot: editing an operation must never change who may
|
|
15
|
+
approve a request already in flight.
|
|
16
|
+
|
|
17
|
+
## Decision
|
|
18
|
+
|
|
19
|
+
`Concerns::Immutable` installs `before_update` and `before_destroy` callbacks that both raise
|
|
20
|
+
`ActiveRecord::ReadOnlyRecord`. It is included by `Event`, `QuorumPermission`,
|
|
21
|
+
`QuorumEligibleActor` and `ApprovalQuorum`.
|
|
22
|
+
|
|
23
|
+
It raises `ActiveRecord::ReadOnlyRecord`, not a `ChangeRequests::Error`: this is an unsupported
|
|
24
|
+
operation, not a refused domain transition ([ADR-0012](0012-declared-error-taxonomy.md)).
|
|
25
|
+
|
|
26
|
+
`ON DELETE CASCADE` bypasses it, by design — deleting a request takes its whole graph with it.
|
|
27
|
+
|
|
28
|
+
## Consequences
|
|
29
|
+
|
|
30
|
+
### Positive
|
|
31
|
+
|
|
32
|
+
- History cannot be rewritten through the model layer, including by the gem's own commands.
|
|
33
|
+
- No `dependent:` option is needed, or wanted, on the associations that point at these rows; the
|
|
34
|
+
database cascade is the only deletion path.
|
|
35
|
+
|
|
36
|
+
### Negative
|
|
37
|
+
|
|
38
|
+
- Code that needs such rows gone has to delete a parent and let the cascade do it. Removing an
|
|
39
|
+
approval's quorum links, for instance, means deleting the approval — the obvious
|
|
40
|
+
`approval.quorums.destroy_all` raises.
|
|
41
|
+
- Enforcement is application-level. A host that wants it below the application adds `DO INSTEAD
|
|
42
|
+
NOTHING` rules itself; see [docs/08_events_and_notifications.md](../08_events_and_notifications.md).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# ADR-0008: Model staged, multi-quorum approval from the first migration
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The common case is "two people with this permission must approve". The cases that arrive later are
|
|
9
|
+
"one Admin **or** two Owners", "one Admin **and** two Owners, then a Director", and "only these two
|
|
10
|
+
named people". A `required_approvals` integer on the request row expresses the first and none of the
|
|
11
|
+
others.
|
|
12
|
+
|
|
13
|
+
Retrofitting stages onto a shipped gem means a schema migration in every host application, plus a
|
|
14
|
+
data migration for every request in flight.
|
|
15
|
+
|
|
16
|
+
## Decision
|
|
17
|
+
|
|
18
|
+
The schema is staged and multi-quorum from the first migration. A request has ordered **stages**; a
|
|
19
|
+
stage has one or more **quorums**; a quorum is a threshold plus an eligibility set, expressed as
|
|
20
|
+
permission rows (permission × actor type, independently nullable) and named-actor rows.
|
|
21
|
+
|
|
22
|
+
- Stages are always sequential. Parallelism within a step is one stage holding several quorums;
|
|
23
|
+
ordered groups are consecutive stages. There is no request-level mode column.
|
|
24
|
+
- A stage's `satisfied_by` is `any_quorum` or `all_quorums`. One word is the whole difference
|
|
25
|
+
between "OR" and "AND".
|
|
26
|
+
- Counting approvals is the only rule. There is no rule column and no expression language.
|
|
27
|
+
|
|
28
|
+
A flat 1-of-N request is simply a request with one stage holding one quorum.
|
|
29
|
+
|
|
30
|
+
## Consequences
|
|
31
|
+
|
|
32
|
+
### Positive
|
|
33
|
+
|
|
34
|
+
- Every workflow shape the gem intends to support is already representable; later milestones add the
|
|
35
|
+
declaration syntax and the evaluation, not tables.
|
|
36
|
+
- Approval policy is never duplicated onto the request row. The stage and quorum rows *are* the
|
|
37
|
+
frozen snapshot ([ADR-0007](0007-append-only-audit-trail.md)).
|
|
38
|
+
|
|
39
|
+
### Negative
|
|
40
|
+
|
|
41
|
+
- Five tables where one integer column would have served the common case, and four joins to answer
|
|
42
|
+
"can this person approve?".
|
|
43
|
+
- Creating a request writes a small graph rather than a row.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# ADR-0009: Let the host own the schema as an ordinary migration
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
An engine with tables has to get them into the host's database. The alternatives are to manage the
|
|
9
|
+
schema from inside the engine — migrating on boot, or keeping a private migration path — or to hand
|
|
10
|
+
the host a migration and let it run the same way as every other migration in the application.
|
|
11
|
+
|
|
12
|
+
Engine-managed schemas are opaque: they do not appear in the host's `schema.rb`, do not roll back
|
|
13
|
+
with the host's own `db:rollback`, and change the database as a side effect of deploying a gem
|
|
14
|
+
version.
|
|
15
|
+
|
|
16
|
+
## Decision
|
|
17
|
+
|
|
18
|
+
The gem ships a migration template. It is copied into the host's `db/migrate` and is thereafter the
|
|
19
|
+
host's file, run, rolled back and reviewed like any other. The gem never creates, alters or migrates
|
|
20
|
+
a table at runtime.
|
|
21
|
+
|
|
22
|
+
Two rules hold inside that migration:
|
|
23
|
+
|
|
24
|
+
- **No foreign key points at a host table**, and none ever will
|
|
25
|
+
([ADR-0003](0003-actor-references-as-triples.md)).
|
|
26
|
+
- Every foreign key *between* gem tables is `ON DELETE CASCADE`, so deleting a request removes its
|
|
27
|
+
whole graph in one statement.
|
|
28
|
+
|
|
29
|
+
## Consequences
|
|
30
|
+
|
|
31
|
+
### Positive
|
|
32
|
+
|
|
33
|
+
- The schema is visible in code review, in `schema.rb`, and in whatever migration tooling the host
|
|
34
|
+
already uses.
|
|
35
|
+
- It rolls back cleanly to zero and migrates straight back up, and leaves the host's own tables
|
|
36
|
+
untouched when it does — both asserted by the suite.
|
|
37
|
+
- Installing the gem changes no data until someone runs a migration.
|
|
38
|
+
|
|
39
|
+
### Negative
|
|
40
|
+
|
|
41
|
+
- A host that installed an earlier version has to take later schema changes as further migrations,
|
|
42
|
+
which the gem must ship and version carefully.
|
|
43
|
+
- A host can edit its copy. Divergence is possible and undetectable from inside the gem.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# ADR-0010: Require every operation to be declared in a registry
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Execution eventually has to turn a stored `operation_key` into a real method call. Resolving that
|
|
9
|
+
from the strings on the request row means `constantize` plus `public_send` on data, which turns any
|
|
10
|
+
endpoint that can write a row into remote code execution.
|
|
11
|
+
|
|
12
|
+
The related question is where approval policy comes from. If the caller passes thresholds and
|
|
13
|
+
permissions to `request!`, then policy is caller input, and every call site can get it wrong.
|
|
14
|
+
|
|
15
|
+
## Decision
|
|
16
|
+
|
|
17
|
+
A host declares its operations up front:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
ChangeRequests.operations.define "members.update_roles" do |op|
|
|
21
|
+
op.version = "2026-09-11"
|
|
22
|
+
op.service = "Members::UpdateRoles"
|
|
23
|
+
op.approvals permissions: %w(member_admin), required: 2
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- The registry is the **dispatch allowlist**. Execution resolves `operation_key → (service,
|
|
28
|
+
method_name)` from the declaration, never from the stored strings, which are audit data only.
|
|
29
|
+
- **Approval policy comes from the declaration**, so every call that raises a request is identical
|
|
30
|
+
no matter how elaborate the workflow is.
|
|
31
|
+
- The resolved workflow is **frozen onto the request at creation** ([ADR-0008](0008-staged-multi-quorum-schema.md)).
|
|
32
|
+
Editing an operation never reaches a request already in flight.
|
|
33
|
+
- `op.version` is mandatory and is validated at declaration time, so the initializer's own line
|
|
34
|
+
number reports the mistake.
|
|
35
|
+
- `operations[key]` returns **nil** rather than raising. An undeclared operation is a refusal each
|
|
36
|
+
guard words for itself — commenting on a stranded request stays open, everything else does not.
|
|
37
|
+
|
|
38
|
+
`op.approvals` *describes* a workflow; creating a request is what materialises it into rows.
|
|
39
|
+
|
|
40
|
+
## Consequences
|
|
41
|
+
|
|
42
|
+
### Positive
|
|
43
|
+
|
|
44
|
+
- A stored string never reaches `constantize`, so the hole stays closed even if a careless endpoint
|
|
45
|
+
lets someone write a row.
|
|
46
|
+
- What a request will invoke is legible from the row itself, not only from live configuration.
|
|
47
|
+
- Removing a declaration is reversible: the requests it strands refuse and become invisible, and
|
|
48
|
+
nothing is destroyed until someone runs the cleanup task deliberately.
|
|
49
|
+
|
|
50
|
+
### Negative
|
|
51
|
+
|
|
52
|
+
- Hosts must declare operations before using them. This is the one thing the gem asks that the
|
|
53
|
+
obvious alternatives do not.
|
|
54
|
+
- The registry is process-global mutable state, which the test suite has to isolate per example.
|
|
55
|
+
- Today the registry carries the declaration attributes and the `op.approvals` shorthand only; the
|
|
56
|
+
full workflow DSL, boot-time verification and `ChangeRequests.request!` arrive with a later
|
|
57
|
+
milestone.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# ADR-0011: Attribute gem-originated actions to a System sentinel actor
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
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
|
|
10
|
+
trail still has to say who did it.
|
|
11
|
+
|
|
12
|
+
Leaving the actor columns NULL makes every reader handle a null case, and makes "nobody" and "we
|
|
13
|
+
forgot to record it" indistinguishable.
|
|
14
|
+
|
|
15
|
+
## Decision
|
|
16
|
+
|
|
17
|
+
`ChangeRequests::SYSTEM_ACTOR` is a frozen triple, `{ type: "System", id: "system", label:
|
|
18
|
+
"System" }`, written into the actor columns like any other actor
|
|
19
|
+
([ADR-0003](0003-actor-references-as-triples.md)). Columns that may carry it opt in explicitly;
|
|
20
|
+
`System` is otherwise not an acceptable actor type.
|
|
21
|
+
|
|
22
|
+
The id is the non-castable string `"system"`, not `"0"`. Actor ids share a string column with host
|
|
23
|
+
classes that may legitimately have string primary keys, and `"0"` is a value one of them could hold.
|
|
24
|
+
|
|
25
|
+
## Consequences
|
|
26
|
+
|
|
27
|
+
### Positive
|
|
28
|
+
|
|
29
|
+
- Every event has an actor, and readers need no null branch.
|
|
30
|
+
- `System` never appears in `config.actor_types`, so it cannot be mistaken for a registered class,
|
|
31
|
+
and no host record can collide with its id.
|
|
32
|
+
|
|
33
|
+
### Negative
|
|
34
|
+
|
|
35
|
+
- A host whose own actor class is literally named `System` cannot register it.
|
|
36
|
+
- The sentinel is a magic value, and the columns that accept it have to say so one by one.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# ADR-0012: Declare the whole error taxonomy before raising any of it
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Hosts rescue this gem's errors in one place — typically `rescue_from ChangeRequests::Error` in a
|
|
9
|
+
controller, with finer branches for the cases they handle specially. That code depends on the
|
|
10
|
+
*ancestry* of each error class, not just its name. Introducing `NotApprovable` in one milestone as a
|
|
11
|
+
`StandardError` and re-parenting it under `TransitionError` in the next silently changes which
|
|
12
|
+
`rescue` clause catches it in every host that upgraded.
|
|
13
|
+
|
|
14
|
+
## Decision
|
|
15
|
+
|
|
16
|
+
`errors.rb` declares the complete hierarchy up front, including errors no milestone raises yet.
|
|
17
|
+
Everything descends from `ChangeRequests::Error`; refused transitions descend from
|
|
18
|
+
`TransitionError`, execution failures from `ExecutionError`.
|
|
19
|
+
|
|
20
|
+
`TransitionError` carries `#request` and `#reason`. **`reason` is the contract** — controllers branch
|
|
21
|
+
on it and views render it as a disabled button's tooltip — while the message is for humans and is
|
|
22
|
+
translated through the same key the guard uses, so a disabled button and a raised error cannot word
|
|
23
|
+
the same refusal differently.
|
|
24
|
+
|
|
25
|
+
Errors raised for unsupported operations rather than refused domain transitions stay in ActiveRecord's
|
|
26
|
+
taxonomy; see [ADR-0007](0007-append-only-audit-trail.md).
|
|
27
|
+
|
|
28
|
+
## Consequences
|
|
29
|
+
|
|
30
|
+
### Positive
|
|
31
|
+
|
|
32
|
+
- A host's `rescue` clauses keep meaning the same thing across gem versions.
|
|
33
|
+
- The taxonomy doubles as a specification of what can go wrong, readable in one file.
|
|
34
|
+
- Guard and command cannot disagree about why something was refused.
|
|
35
|
+
|
|
36
|
+
### Negative
|
|
37
|
+
|
|
38
|
+
- Classes exist that nothing raises yet, which reads as dead code until the milestone that uses them
|
|
39
|
+
lands.
|
|
40
|
+
- The reason symbols are now public API and have to be versioned as carefully as the class names.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# ADR-0013: Pin `json` to `~> 2.7` as a runtime dependency
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
json 3.0 removed the positional-options form of `JSON.parse(source, options)`. ActiveSupport 8.1
|
|
9
|
+
still calls it that way, and json 3 is the default bundled version under Ruby 4. The result is that
|
|
10
|
+
**every `jsonb` read raises** `ArgumentError: wrong number of arguments (given 2, expected 1)` from
|
|
11
|
+
three frames inside ActiveSupport — with no hint that a gem version is the cause.
|
|
12
|
+
|
|
13
|
+
The gem stores `payload` and `payload_labels` as `jsonb`, so this is not an edge case: it breaks
|
|
14
|
+
reading any request at all.
|
|
15
|
+
|
|
16
|
+
## Decision
|
|
17
|
+
|
|
18
|
+
`change_requests.gemspec` declares `spec.add_dependency "json", "~> 2.7"`.
|
|
19
|
+
|
|
20
|
+
A development pin in the `Gemfile` was the first attempt and was not enough: it protects the gem's
|
|
21
|
+
own suite and nobody else, and it does not even cover the gem's own matrix gemfiles, which inherit
|
|
22
|
+
nothing from the root `Gemfile`. A runtime dependency is pulled in by the `gemspec` directive, so it
|
|
23
|
+
constrains the gem's suite, every matrix gemfile and every host.
|
|
24
|
+
|
|
25
|
+
## Consequences
|
|
26
|
+
|
|
27
|
+
### Positive
|
|
28
|
+
|
|
29
|
+
- A host cannot resolve this gem onto a json that breaks it.
|
|
30
|
+
- The constraint is declared once, where the rest of the gem's requirements live.
|
|
31
|
+
|
|
32
|
+
### Negative
|
|
33
|
+
|
|
34
|
+
- The gem constrains a dependency it does not itself use, on behalf of ActiveSupport. It is a
|
|
35
|
+
workaround for someone else's incompatibility and should be removed once ActiveSupport stops
|
|
36
|
+
calling `JSON.parse` positionally.
|
|
37
|
+
- A host that needs json 3 for another gem has a genuine conflict and no way around it.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# ADR-0014: Enforce the architectural boundaries executably
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
[ADR-0001](0001-headless-domain-core.md) is only true while it stays true. One `Rails.logger` in a
|
|
9
|
+
domain file, one `ActiveJob` reference outside a `defined?` guard, and the headless guarantee is
|
|
10
|
+
gone — with nothing failing, because the gem's own suite loads Rails for the dummy application and
|
|
11
|
+
would never notice.
|
|
12
|
+
|
|
13
|
+
The same applies to the guarantees that only exist at a process boundary: that requiring the gem
|
|
14
|
+
*without* Rails defines no engine, and that requiring it *with* Rails does.
|
|
15
|
+
|
|
16
|
+
## Decision
|
|
17
|
+
|
|
18
|
+
Two mechanisms, both wired into `rake ci`:
|
|
19
|
+
|
|
20
|
+
- **Static.** `Archspec.rb` states the boundaries as executable rules: every file under
|
|
21
|
+
`lib/change_requests/` except `engine.rb` belongs to a `:domain` component that cannot reference
|
|
22
|
+
`Rails`, `ActionController`, `ActionView`, `ActionDispatch` or `ActiveJob`, and cannot use the
|
|
23
|
+
`:engine` component. New directories are added to the component as they appear, so a file outside
|
|
24
|
+
the rules is a visible omission rather than a silent exemption.
|
|
25
|
+
- **Runtime.** Guarantees that depend on what a process loaded are asserted in **subprocesses**: a
|
|
26
|
+
probe that boots a real Rails application and one that never requires Rails, each reporting facts
|
|
27
|
+
the parent asserts. The isolation is the process, not the CI job.
|
|
28
|
+
|
|
29
|
+
`rake ci` runs RuboCop, archspec, Brakeman, the specs and a CVE check. CI runs the same tasks in at
|
|
30
|
+
most four jobs.
|
|
31
|
+
|
|
32
|
+
## Consequences
|
|
33
|
+
|
|
34
|
+
### Positive
|
|
35
|
+
|
|
36
|
+
- The boundary fails a build rather than being discovered by a host in production.
|
|
37
|
+
- The rules carry their rationale, so the check explains itself when it fires.
|
|
38
|
+
- Deliberate exceptions are per-line and annotated, which makes them countable.
|
|
39
|
+
|
|
40
|
+
### Negative
|
|
41
|
+
|
|
42
|
+
- A static analyser cannot see dynamic references, and reports a number of unresolved constants and
|
|
43
|
+
unknown receivers it simply cannot judge.
|
|
44
|
+
- Subprocess probes are slower than ordinary examples and harder to debug, since a failure arrives
|
|
45
|
+
as a string of reported facts rather than a stack trace.
|
data/docs/adr/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Architecture Decision Records
|
|
2
|
+
|
|
3
|
+
One record per significant architectural choice: its context, the decision, and what it costs.
|
|
4
|
+
|
|
5
|
+
These describe the gem **as it stands today**. Decisions still ahead of the implementation live in
|
|
6
|
+
`PLAN.md` until the code that makes them real exists.
|
|
7
|
+
|
|
8
|
+
A record is never edited once accepted. It is superseded by a later one, which links back to it.
|
|
9
|
+
|
|
10
|
+
| ADR | Title | Status |
|
|
11
|
+
|-----|-------|--------|
|
|
12
|
+
| [0001](0001-headless-domain-core.md) | Keep the domain core headless | Accepted |
|
|
13
|
+
| [0002](0002-mountable-engine-with-isolated-namespace.md) | Ship the Rails layer as a mountable engine with an isolated namespace | Accepted |
|
|
14
|
+
| [0003](0003-actor-references-as-triples.md) | Reference host actors by a (type, id, label) triple | Accepted |
|
|
15
|
+
| [0004](0004-uuid-primary-keys.md) | Use uuid primary keys for every gem-owned table | Accepted |
|
|
16
|
+
| [0005](0005-string-states-with-check-constraints.md) | Store states as strings with CHECK constraints, not PostgreSQL enums | Accepted |
|
|
17
|
+
| [0006](0006-creation-time-immutability.md) | Enforce creation-time immutability in the gem, not with `attr_readonly` | Accepted |
|
|
18
|
+
| [0007](0007-append-only-audit-trail.md) | Make the audit trail and the eligibility rows append-only | Accepted |
|
|
19
|
+
| [0008](0008-staged-multi-quorum-schema.md) | Model staged, multi-quorum approval from the first migration | Accepted |
|
|
20
|
+
| [0009](0009-host-owned-schema.md) | Let the host own the schema as an ordinary migration | Accepted |
|
|
21
|
+
| [0010](0010-operations-must-be-declared.md) | Require every operation to be declared in a registry | Accepted |
|
|
22
|
+
| [0011](0011-system-sentinel-actor.md) | Attribute gem-originated actions to a System sentinel actor | Accepted |
|
|
23
|
+
| [0012](0012-declared-error-taxonomy.md) | Declare the whole error taxonomy before raising any of it | Accepted |
|
|
24
|
+
| [0013](0013-json-runtime-pin.md) | Pin `json` to `~> 2.7` as a runtime dependency | Accepted |
|
|
25
|
+
| [0014](0014-executable-architecture-rules.md) | Enforce the architectural boundaries executably | Accepted |
|