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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -0
  3. data/docs/adr/0001-headless-domain-core.md +40 -0
  4. data/docs/adr/0002-mountable-engine-with-isolated-namespace.md +38 -0
  5. data/docs/adr/0003-actor-references-as-triples.md +43 -0
  6. data/docs/adr/0004-uuid-primary-keys.md +35 -0
  7. data/docs/adr/0005-string-states-with-check-constraints.md +43 -0
  8. data/docs/adr/0006-creation-time-immutability.md +39 -0
  9. data/docs/adr/0007-append-only-audit-trail.md +42 -0
  10. data/docs/adr/0008-staged-multi-quorum-schema.md +43 -0
  11. data/docs/adr/0009-host-owned-schema.md +43 -0
  12. data/docs/adr/0010-operations-must-be-declared.md +57 -0
  13. data/docs/adr/0011-system-sentinel-actor.md +36 -0
  14. data/docs/adr/0012-declared-error-taxonomy.md +40 -0
  15. data/docs/adr/0013-json-runtime-pin.md +37 -0
  16. data/docs/adr/0014-executable-architecture-rules.md +45 -0
  17. data/docs/adr/README.md +25 -0
  18. data/lib/change_requests/authorization/callable.rb +30 -0
  19. data/lib/change_requests/authorization/permissions.rb +66 -1
  20. data/lib/change_requests/commands/approve.rb +69 -0
  21. data/lib/change_requests/commands/base.rb +113 -0
  22. data/lib/change_requests/commands/cancel.rb +43 -0
  23. data/lib/change_requests/commands/comment.rb +40 -0
  24. data/lib/change_requests/commands/create.rb +169 -0
  25. data/lib/change_requests/commands/expire.rb +27 -0
  26. data/lib/change_requests/commands/reject.rb +77 -0
  27. data/lib/change_requests/commands/unapprove.rb +49 -0
  28. data/lib/change_requests/configuration.rb +22 -1
  29. data/lib/change_requests/errors.rb +45 -26
  30. data/lib/change_requests/guards/approve.rb +31 -0
  31. data/lib/change_requests/guards/base.rb +201 -0
  32. data/lib/change_requests/guards/cancel.rb +32 -0
  33. data/lib/change_requests/guards/comment.rb +32 -0
  34. data/lib/change_requests/guards/execute.rb +48 -0
  35. data/lib/change_requests/guards/expire.rb +37 -0
  36. data/lib/change_requests/guards/reject.rb +38 -0
  37. data/lib/change_requests/guards/unapprove.rb +41 -0
  38. data/lib/change_requests/models/approval.rb +1 -1
  39. data/lib/change_requests/models/attempt.rb +3 -1
  40. data/lib/change_requests/models/concerns/actor_columns.rb +12 -6
  41. data/lib/change_requests/models/event.rb +3 -1
  42. data/lib/change_requests/models/quorum.rb +7 -2
  43. data/lib/change_requests/models/request.rb +11 -2
  44. data/lib/change_requests/models/stage.rb +10 -3
  45. data/lib/change_requests/operation.rb +6 -2
  46. data/lib/change_requests/operations.rb +14 -4
  47. data/lib/change_requests/translation.rb +3 -1
  48. data/lib/change_requests/version.rb +1 -1
  49. data/lib/change_requests/workflow.rb +3 -1
  50. data/lib/change_requests.rb +9 -3
  51. data/lib/generators/change_requests/install/templates/migration.rb.tt +5 -0
  52. metadata +33 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04c87793dcb0c59f3124d3924f6599486406f3df811c5f61fc1be2840b015c0c
4
- data.tar.gz: fd410e8de4959c5f1155ec334fc09731370040582c56a6d2b1b9f387f08828cf
3
+ metadata.gz: 205a020929c71027848bc891897a26676a96fbc2c77a6d2e32aa5cedc577109c
4
+ data.tar.gz: 172b2e90ff436bd10e42e5a30eb18052b65b20bf660156efec238b58aa58abf5
5
5
  SHA512:
6
- metadata.gz: bc2b298c8470e7f4389361a50870d146f6f7181a160e710148e4b26ffed3f55cfa3b6d0f184259760f06bf13aca98fd2e4bf068846261a0d900595db42e514eb
7
- data.tar.gz: a3fc1ab98a25f1a88c5f7da19139dcbd2d48b889fc5fdfcbf4d8a974c2b213c2401cbd74fa3638fcdf7257f02ef7049c6a08c7aaa08144470f68159f0198fe14
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.
@@ -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 |