approval_engine 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +138 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +249 -0
  5. data/app/controllers/approval_engine/application_controller.rb +10 -0
  6. data/app/controllers/approval_engine/approvals_controller.rb +29 -0
  7. data/app/helpers/approval_engine/application_helper.rb +27 -0
  8. data/app/jobs/approval_engine/application_job.rb +4 -0
  9. data/app/jobs/approval_engine/process_outbox_job.rb +100 -0
  10. data/app/jobs/approval_engine/timeout_sweep_job.rb +19 -0
  11. data/app/models/approval_engine/application_record.rb +5 -0
  12. data/app/models/approval_engine/approval.rb +144 -0
  13. data/app/models/approval_engine/approval_plan.rb +60 -0
  14. data/app/models/approval_engine/audit_log.rb +28 -0
  15. data/app/models/approval_engine/consensus.rb +37 -0
  16. data/app/models/approval_engine/delegation.rb +34 -0
  17. data/app/models/approval_engine/history.rb +62 -0
  18. data/app/models/approval_engine/outbox_event.rb +47 -0
  19. data/app/models/approval_engine/step.rb +271 -0
  20. data/app/models/approval_engine/template_step.rb +22 -0
  21. data/app/models/approval_engine/track.rb +127 -0
  22. data/app/models/approval_engine/track_template.rb +23 -0
  23. data/app/models/approval_engine/trigger_rule.rb +17 -0
  24. data/app/models/concerns/approval_engine/approvable.rb +183 -0
  25. data/app/services/approval_engine/approval_builder.rb +172 -0
  26. data/app/services/approval_engine/iteration_builder.rb +44 -0
  27. data/app/services/approval_engine/rule_evaluator.rb +119 -0
  28. data/app/views/approval_engine/approvals/index.html.erb +38 -0
  29. data/app/views/approval_engine/approvals/show.html.erb +55 -0
  30. data/app/views/layouts/approval_engine/dashboard.html.erb +49 -0
  31. data/config/routes.rb +8 -0
  32. data/db/migrate/20260614103434_create_approval_engine_core_tables.rb +107 -0
  33. data/db/migrate/20260614103435_create_approval_engine_infrastructure_tables.rb +35 -0
  34. data/db/migrate/20260614104809_create_approval_engine_blueprint_tables.rb +60 -0
  35. data/db/migrate/20260616000001_add_trigger_rule_to_approvals.rb +14 -0
  36. data/db/migrate/20260617000001_add_approvals_required_to_approvals.rb +11 -0
  37. data/db/migrate/20260617000002_add_delivery_tracking_to_outbox_events.rb +11 -0
  38. data/lib/approval_engine/approval_exposure.rb +66 -0
  39. data/lib/approval_engine/configuration.rb +67 -0
  40. data/lib/approval_engine/engine.rb +17 -0
  41. data/lib/approval_engine/model_extensions.rb +20 -0
  42. data/lib/approval_engine/version.rb +3 -0
  43. data/lib/approval_engine.rb +12 -0
  44. data/lib/generators/approval_engine/install/install_generator.rb +34 -0
  45. data/lib/generators/approval_engine/install/templates/POST_INSTALL +55 -0
  46. data/lib/generators/approval_engine/install/templates/approval_engine.rb +21 -0
  47. data/lib/generators/approval_engine/views/templates/approvals/index.html.erb +17 -0
  48. data/lib/generators/approval_engine/views/templates/approvals_controller.rb +32 -0
  49. data/lib/generators/approval_engine/views/views_generator.rb +33 -0
  50. metadata +129 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5d2b7c82e4d4b1fe2b40cdc8e70425049e1b08ba46544f1d220c9c85c205644b
4
+ data.tar.gz: 66ca2e891a150af9148a54277d7d47dee9599732e69223e10c18d63c6a665460
5
+ SHA512:
6
+ metadata.gz: 7b953537aeae8f200b23ab5d92956a8aae8742c837891d66684ae901268788b2612255b3647a5695c674dd08d0a1a774574afc0df651139fd23b02a1b60f837f
7
+ data.tar.gz: 119a6e6869463e1a97515557882e11ead9c818d2d2e847f2ceb36ed6ae85ae772c9638ca6cc2bfb246999843f44754f988eec1ececb89f6815d79b642301ec8a
data/CHANGELOG.md ADDED
@@ -0,0 +1,138 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2026-06-17
9
+
10
+ First public release. The API below is stable.
11
+
12
+ ### Added
13
+
14
+ - Immutable, append-only approval ledger (`Approval` → `Track` → `Step`) with
15
+ forward-only state transitions and write-once `AuditLog` rows.
16
+ - `has_approvals` model macro and the `exposes_for_approval`
17
+ anti-corruption DSL for whitelisting attributes to the rules engine.
18
+ - Dynamic, tenant-scoped routing via JSON Logic (`shiny_json_logic`), with
19
+ fail-closed quarantine on malformed rules.
20
+ - `preview_approval(event:)` — a side-effect-free dry run that returns
21
+ a `ApprovalPlan` describing what an action *would* trigger (which template,
22
+ steps, and assignees), so hosts can warn users before they commit.
23
+ - `Step.actionable_by(actor)` — an approver's inbox scope: pending steps assigned
24
+ to them plus those they cover via an active delegation. Plus `Step#target` to
25
+ show what each step is approving. Powers a "my pending approvals" UI.
26
+ - Cycle-time facts on every step: `activated_at` (became actionable) and
27
+ `decided_at` (human resolved it), stamped automatically — with `step.waiting_for`
28
+ / `step.time_to_decision` readers and `approval.current_bottleneck` (the
29
+ longest-pending step). The dashboard shows a time-in-step column. SLA
30
+ thresholds, reminders, and escalation stay the host's to define.
31
+ - Per-step timeouts: `timeout_after` on a template step (the clock starts when the
32
+ step becomes actionable), swept by `ApprovalEngine::TimeoutSweepJob` /
33
+ `Step.sweep_timeouts!`, surfaced via the `on_step_timeout(step)` host callback. A
34
+ timeout fires once and never decides the step — `step.expire!` is the honest
35
+ terminal denial (a distinct `expired` state, recorded with no actual actor).
36
+ **ApprovalEngine never auto-approves: silence is not consent.**
37
+ - `record.approval_history` — a read-only `History` view of everything a record
38
+ has gone through: all approvals (newest first, eager-loaded), and a
39
+ chronological timeline of step actions with actors and comments. The host
40
+ decides who may see it.
41
+ - `Model.approval_event_name(:create)` returns the conventional auto-trigger
42
+ event name, so rules can reference it instead of a hand-typed literal that
43
+ could silently drift (raises on an unknown lifecycle).
44
+ - Trigger approvals on any event/transition — `run_approval!(event:)`
45
+ accepts any event name, and `has_approvals(on: [:create, :update])`
46
+ auto-routes on update too (gated per-lifecycle via `trigger_approval?`).
47
+ - `approval_candidates(event:)` lists every matching approval (not just the
48
+ top-priority one), and `run_approval!(templates:)` starts a chosen
49
+ one (or several, as parallel tracks) — so a user can decide instead of the
50
+ engine auto-routing by priority.
51
+ - `approval.trigger_rule` — provenance: the `TriggerRule` that auto-routed an
52
+ approval (nil for a manual `run_approval!(templates:)` start), captured at
53
+ build time so it stays stable even if the rule is edited or retired later.
54
+ - Consensus per layer via `approvals_required`: `:any`, `:all`, `:majority`, a
55
+ percentage like `"60%"`, or a fixed count — resolved against the live group
56
+ size, so authors express policy without hard-coding headcount.
57
+ - `track.layer_tally(layer)` — a public read of a layer's live consensus tally
58
+ (`required` / `approved` / `rejected` / `pending` / `waiting` / `group_size` /
59
+ `outcome`), so a UI can show "N of M approved" and *why* a layer is
60
+ met/failed/undecided without re-deriving the consensus math the engine owns. A
61
+ layer that hasn't opened yet (all steps still `waiting`) reads as `:undecided`,
62
+ not `:failed` — `waiting` steps count as still-reachable approvals.
63
+ - Consensus-aware rejection: a reject respects the layer's policy, failing the
64
+ approval as soon as the required approvals become unreachable (one no for
65
+ `:all`; every actor for `:any`; too few voters left to reach a count) rather
66
+ than vetoing on the first no. A failed layer never advances.
67
+ - Sequential multi-layer tracks with automatic layer activation.
68
+ - Scatter-gather parallel tracks via `ApprovalBuilder.build_parallel!` — one
69
+ approval gathers across several simultaneous tracks. The gather is
70
+ consensus-aware: `approvals_required` (on `build_parallel!` /
71
+ `run_approval!(templates:, approvals_required:)`) says how many tracks must
72
+ approve — `:all` by default (unanimity, the historical behaviour), but also
73
+ `:any` / `:majority` / `"60%"` / a fixed count, so "2 of 3 departments must
74
+ sign off" is expressible. One track rejecting no longer vetoes a still-reachable
75
+ gather; a count that exceeds the number of tracks raises at build time.
76
+ - Append-only "approval changes" cycles (`request_changes!`) that send an approval
77
+ back for a fresh iteration while preserving history.
78
+ - `approval.cancel!(reason:)` — withdraw an in-flight approval: the third terminal
79
+ outcome beside approved and rejected, for when the thing being approved is voided
80
+ or retracted. Cancels open tracks/steps, keeps history, fires `after_cancelled`.
81
+ - `step.reassign!(to:, by:, comment:)` — hand a stuck step to another actor without
82
+ restarting the flow (the escalation partner to timeouts), recorded on the ledger
83
+ and firing `after_step_reassigned`.
84
+ - `ApprovalEngine::Error` base class for every error the engine raises
85
+ (`BuilderError`, `EvaluationError` reparented), so a host can rescue one type.
86
+ - Time-bound delegation with intended-vs-actual actor auditing.
87
+ - Transactional outbox that relays host callbacks and
88
+ `ActiveSupport::Notifications` asynchronously via ActiveJob.
89
+ - Pessimistic, approval-scoped locking that makes double-approvals impossible.
90
+ - `approval_engine:install` and `approval_engine:views` generators.
91
+ - A read-only, mountable ops dashboard.
92
+
93
+ ### Onboarding & hygiene
94
+
95
+ - Reworked the README golden path (verified end-to-end): a "What you must
96
+ provide" checklist, a self-contained quickstart, a mandatory `preview ...
97
+ .triggered?` verification step, and loud warnings about the silent-failure
98
+ traps (unset tenant, `event_name` mismatch, `draft` templates, unexposed vars).
99
+ - Added a JSON Logic "Authoring rules" cookbook section (and/or/in/equality
100
+ examples) and rewrote the install generator's POST_INSTALL into a warned,
101
+ ordered checklist.
102
+ - Removed the redundant `ApprovalEngine::Web` alias (mount `ApprovalEngine::Engine`),
103
+ pruned generator dead code (unused mailer, layout, rake stub), normalized the
104
+ blueprint migration, and retired the stale handoff doc.
105
+
106
+ ### Hardened
107
+
108
+ - Eliminated N+1s in the dashboard (approval list track-counts, detail-page
109
+ actors) and `approval_history`; `History#events` is now a single bounded,
110
+ DB-ordered query with preloaded actors (proven N+1-free by query-count tests).
111
+ - Added hot-path indexes: track `(request_id, status)`, step layer-consensus
112
+ `(branch_id, iteration, layer, status)`, approval `(target, created_at)`,
113
+ audit-log `(tenant_id, created_at)`, trigger-rule resolution, a delegation
114
+ time-window composite, and a partial index for the outbox drain.
115
+ - DB `CHECK` constrains `approvals_required` to the accepted vocabulary on both
116
+ steps and template-steps (a raw insert can't store a spec the engine can't
117
+ resolve).
118
+ - `drain!` is bounded (age + limit) so a backlog can't enqueue everything at once.
119
+ - Outbox relay now holds its row lock for the whole transaction (concurrent
120
+ workers can't double-deliver), retries with backoff (`retry_on`), retires
121
+ events whose target was purged instead of looping forever, and `drain!` skips
122
+ in-flight events. Host callbacks are at-least-once and unordered — make them
123
+ idempotent. Exhausted retries now dead-letter the row (`failed_at`) so `drain!`
124
+ can't resurrect a poison event forever; delivery errors are recorded in a
125
+ separate `delivery_error` column so a retry never clobbers the semantic reason
126
+ (`error_payload`) the host callback reads; and the timeout sweep isolates a
127
+ single step's failure so it can't starve the rest of the batch.
128
+ - Database `CHECK` constraints enforce every status and `approvals_required`
129
+ value, so the ledger can't be corrupted by a raw write — not just Ruby
130
+ validations.
131
+ - Consensus/layer edge cases that could silently strand an approval in `pending`
132
+ are now handled: non-contiguous layers activate the next existing layer, a
133
+ required count exceeding the resolved group raises at build time, and `:all`
134
+ excludes cancelled siblings from its denominator.
135
+ - A misconfigured `actor_class` now raises an actionable `BuilderError` naming
136
+ the setting, instead of a raw `NameError`.
137
+
138
+ [1.0.0]: https://github.com/Harry-kp/approval_engine/releases/tag/v1.0.0
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Harry-kp
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,249 @@
1
+ # ApprovalEngine
2
+
3
+ [![CI](https://github.com/Harry-kp/approval_engine/actions/workflows/ci.yml/badge.svg)](https://github.com/Harry-kp/approval_engine/actions/workflows/ci.yml)
4
+ ![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-CC342D)
5
+ ![Rails](https://img.shields.io/badge/rails-%3E%3D%207.0.8-D30001)
6
+ ![License: MIT](https://img.shields.io/badge/license-MIT-blue)
7
+
8
+ Multi-tenant, immutable-ledger human approval flows for Rails.
9
+
10
+ Use it when a manager approves an invoice, then a CFO. Or Legal and IT
11
+ in parallel. Or "any two of five reviewers." ApprovalEngine supplies the
12
+ generic machinery: an append-only ledger, race-safe transitions, runtime
13
+ routing rules, and async side-effects. You decide what gets approved, who
14
+ approves, and what happens next.
15
+
16
+ ## Is this for you?
17
+
18
+ Use it when you have:
19
+
20
+ - Multi-step, human-in-the-loop approvals, sequential or parallel
21
+ - Routing rules that admins change at runtime, without a deploy
22
+ - A need to audit who approved what, when, and on whose behalf
23
+ - Concurrency that must never double-approve
24
+
25
+ Look elsewhere when:
26
+
27
+ - You just need a boolean `approved` flag. A column and a method are simpler.
28
+ - You need a state machine for non-approval domains. Try
29
+ [AASM](https://github.com/aasm/aasm) or
30
+ [state_machines](https://github.com/state-machines/state_machines).
31
+ - You're not on PostgreSQL. The routing engine needs `jsonb` and `gin`.
32
+
33
+ ## Installation
34
+
35
+ Add this line to your application's **Gemfile**:
36
+
37
+ ```ruby
38
+ gem "approval_engine"
39
+ ```
40
+
41
+ And then execute:
42
+
43
+ ```sh
44
+ bundle install
45
+ rails generate approval_engine:install
46
+ rails db:migrate
47
+ ```
48
+
49
+ The generator copies migrations and an initializer, and prints next steps.
50
+
51
+ ## Quickstart
52
+
53
+ Teach your actor class to resolve approval groups. The engine creates one
54
+ step per returned record. `target` is the record being approved (e.g. the
55
+ Invoice); this example ignores it, but you can use it for record-scoped
56
+ groups like "this invoice's department head".
57
+
58
+ ```ruby
59
+ class User < ApplicationRecord
60
+ def self.resolve_approval_group(group_name, target)
61
+ where(role: group_name) # `target` available for record-scoped resolution
62
+ end
63
+ end
64
+ ```
65
+
66
+ Arm a model and declare the attributes the rules engine may read.
67
+
68
+ ```ruby
69
+ class Invoice < ApplicationRecord
70
+ has_approvals
71
+
72
+ exposes_for_approval do
73
+ attribute :amount, type: :decimal
74
+ end
75
+
76
+ def after_approved
77
+ PaymentService.disburse_funds!(self)
78
+ end
79
+ end
80
+ ```
81
+
82
+ Define a template, its ordered steps, and the rule that triggers it.
83
+
84
+ ```ruby
85
+ template = ApprovalEngine::TrackTemplate.create!(
86
+ tenant_id: "acme", name: "High-value invoice", status: "active"
87
+ )
88
+ template.template_steps.create!(name: "Manager", layer: 1, assigned_group: "manager")
89
+ template.template_steps.create!(name: "CFO", layer: 2, assigned_group: "cfo")
90
+ template.trigger_rules.create!(
91
+ tenant_id: "acme", event_name: "invoice.created",
92
+ condition: { ">" => [{ "var" => "amount" }, 10_000] }
93
+ )
94
+ ```
95
+
96
+ Trigger a run.
97
+
98
+ ```ruby
99
+ invoice = Invoice.create!(amount: 20_000)
100
+ invoice.run_approval!(event: "invoice.created", tenant_id: "acme")
101
+ ```
102
+
103
+ Verify it routed before going further.
104
+
105
+ ```ruby
106
+ invoice.preview_approval(event: "invoice.created", tenant_id: "acme").triggered?
107
+ # => true
108
+ ```
109
+
110
+ Act on a step. `actionable_by` is the approver's inbox, including delegations.
111
+
112
+ ```ruby
113
+ ApprovalEngine::Step.actionable_by(current_user).first.approve!(by: current_user)
114
+ ```
115
+
116
+ ### Gotchas
117
+
118
+ This gem fails closed and silent when misconfigured. If a run doesn't
119
+ trigger, `preview_approval(...).triggered?` tells you why. Check:
120
+
121
+ - The rule's `event_name` matches the event you fire.
122
+ - The template `status` is `"active"`. Draft templates never fire.
123
+ - Every attribute a rule reads is declared in `exposes_for_approval`.
124
+ - `config.current_tenant_method` is set. Until then, auto-routing on
125
+ create is a no-op, so pass `tenant_id:` explicitly.
126
+
127
+ ## See it live
128
+
129
+ Run the demo against a clone of this repo, not your own app. It needs
130
+ PostgreSQL running.
131
+
132
+ ```sh
133
+ bin/demo
134
+ # seeds sample data and boots the dashboard at
135
+ # http://localhost:3000/approval_engine
136
+ ```
137
+
138
+ Or explore the API in a console preloaded with sample data.
139
+
140
+ ```sh
141
+ bin/console
142
+ >> Rails.application.load_seed
143
+ >> ApprovalEngine::Step.pending.first.approve!(by: User.find_by(role: "manager"))
144
+ ```
145
+
146
+ The mounted dashboard lists every approval, filters by status, and drills
147
+ into tracks, steps, and the full audit trail. It is read-only, with
148
+ bundled styling. It has no auth of its own — when you mount it in a real app,
149
+ wrap it in a `constraints`/authenticated route ([recipe](docs/COOKBOOK.md#ui--monitoring)).
150
+
151
+ ## Configuration
152
+
153
+ ```ruby
154
+ # config/initializers/approval_engine.rb
155
+ ApprovalEngine.configure do |config|
156
+ config.actor_class = "User" # who approves
157
+ config.current_tenant_method = -> { Current.account } # anything with #id
158
+ config.outbox_queue = :default # ActiveJob queue for side-effects
159
+ config.raise_on_rule_errors = false # fail closed in production
160
+ end
161
+ ```
162
+
163
+ `current_tenant_method` defaults to `nil`. While it is nil, auto-routing
164
+ on create silently no-ops, since the engine cannot scope the rules.
165
+ Single-tenant apps can return a constant, e.g.
166
+ `-> { Struct.new(:id).new("default") }`.
167
+
168
+ No Redis or Sidekiq required. Side-effects run through ActiveJob, so
169
+ SolidQueue, Sidekiq, or the async adapter all work.
170
+
171
+ ## Core concepts
172
+
173
+ | Term | What it is |
174
+ | --- | --- |
175
+ | Template | The reusable blueprint: ordered layers of steps with consensus rules |
176
+ | Trigger rule | A tenant-scoped JSON Logic condition that selects a template for an event |
177
+ | Approval | One run: a host record fanned out into one or more parallel tracks |
178
+ | Track | One parallel path of layered steps within an approval |
179
+ | Step | One approval slot in the immutable ledger (`approve!` / `reject!` / `request_changes!`) |
180
+ | Consensus | How many approvals a layer needs: `approvals_required` — `:any`, `:all`, `:majority`, a percentage like `"60%"`, or a count |
181
+
182
+ Every run is `Approval -> Track -> Step`, even the one-approver case.
183
+ A single-track run is an approval with one track, not a special path.
184
+ You never build that chain by hand: start a run with
185
+ `run_approval!` and act on a step with `step.approve!`. The
186
+ layers surface only when you need them, such as parallel tracks or the
187
+ dashboard. For a single-track approval, `approval.track` and
188
+ `approval.step` read it back without `.first`.
189
+
190
+ `approvals_required` is one idea used at two levels: within a layer (how many of
191
+ its steps), and across the parallel tracks of a scatter-gather (how many tracks
192
+ must approve — `:all` by default). Beyond the happy path, an approval can be
193
+ withdrawn (`approval.cancel!`) and a stuck step escalated (`step.reassign!`).
194
+
195
+ **Reacting to outcomes** — define any of these on your model and the engine
196
+ calls them (via the outbox, at-least-once and unordered): `after_approved`,
197
+ `after_rejected(reason)`, `after_cancelled(reason)`, `on_quarantined(reason)`,
198
+ `after_step_approved/rejected/changes_requested/expired/reassigned(step)`,
199
+ `on_step_timeout(step)`. Or subscribe to the matching `approval_engine.*`
200
+ [notifications](docs/COOKBOOK.md#notify-another-system-without-coupling-to-my-model).
201
+
202
+ ## Cookbook
203
+
204
+ See **[docs/COOKBOOK.md](docs/COOKBOOK.md)** for copy-paste recipes
205
+ covering every supported case, from "any two of five reviewers" to "Legal
206
+ and IT in parallel" to delegation and requesting changes.
207
+
208
+ ## How it works
209
+
210
+ | Concern | Mechanism |
211
+ | --- | --- |
212
+ | Auditability | Append-only `Step` ledger; requesting changes appends an iteration instead of editing history |
213
+ | Concurrency | Approval-scoped pessimistic lock around every transition, so no double-approvals |
214
+ | Routing | JSON Logic ASTs in `jsonb`, evaluated by [`shiny_json_logic`](https://rubygems.org/gems/shiny_json_logic) |
215
+ | Side-effects | Transactional outbox relayed by ActiveJob, so a down API never rolls back an approval |
216
+ | Safety | A malformed rule quarantines the approval instead of raising |
217
+
218
+ A missing attribute is a clean non-match, since JSON Logic treats it as
219
+ `false`, so the approval just doesn’t start. Only a malformed rule, such
220
+ as an unknown operator, quarantines. The approval never crashes either
221
+ way. Set `config.raise_on_rule_errors = true` to surface errors loudly.
222
+
223
+ For the full design — the model hierarchy, the consensus/rework model, and why
224
+ the outbox exists — see **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)**.
225
+
226
+ ## Development
227
+
228
+ ApprovalEngine needs Ruby 3.1+ and PostgreSQL.
229
+
230
+ ```sh
231
+ bin/setup
232
+ bin/rails app:test
233
+ bundle exec rubocop
234
+ ```
235
+
236
+ Point at any Postgres with `DATABASE_URL` if you're not on the default
237
+ socket. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide.
238
+
239
+ ## Contributing
240
+
241
+ Bug reports and pull approvals are welcome on GitHub at
242
+ https://github.com/Harry-kp/approval_engine. Please read
243
+ [CONTRIBUTING.md](CONTRIBUTING.md) and our
244
+ [Code of Conduct](CODE_OF_CONDUCT.md).
245
+
246
+ ## License
247
+
248
+ Available as open source under the terms of the
249
+ [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,10 @@
1
+ module ApprovalEngine
2
+ # Base controller for the mounted ops dashboard. It deliberately inherits from
3
+ # ActionController::Base (not the host's ApplicationController) so the
4
+ # dashboard is self-contained; wrap the mount in your own auth constraint to
5
+ # protect it.
6
+ class ApplicationController < ActionController::Base
7
+ protect_from_forgery with: :exception
8
+ layout "approval_engine/dashboard"
9
+ end
10
+ end
@@ -0,0 +1,29 @@
1
+ module ApprovalEngine
2
+ # The dashboard's approval list and detail views. Read-only by design — its
3
+ # job is to surface stuck, quarantined and in-flight work without anyone
4
+ # writing SQL, not to act on it.
5
+ class ApprovalsController < ApplicationController
6
+ PAGE_LIMIT = 100
7
+
8
+ def index
9
+ @status = params[:status].presence
10
+ @counts = Approval.group(:status).count
11
+ scope = Approval.all.order(created_at: :desc)
12
+ scope = scope.where(status: @status) if @status
13
+ @approvals = scope.limit(PAGE_LIMIT).to_a
14
+ @total = scope.count
15
+ # One grouped query for all the row counts, instead of N `.tracks.size`.
16
+ @track_counts = Track.where(approval_engine_approval_id: @approvals.map(&:id))
17
+ .group(:approval_engine_approval_id).count
18
+ end
19
+
20
+ def show
21
+ # Preload the whole tree *including* the polymorphic actors the view renders
22
+ # (assigned actor, and each audit log's actual/intended actor) so the page
23
+ # is a fixed handful of queries regardless of how many steps it has.
24
+ @approval = Approval.includes(
25
+ tracks: { steps: [ :assigned_actor, { audit_logs: %i[actual_actor intended_actor] } ] }
26
+ ).find(params[:id])
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,27 @@
1
+ module ApprovalEngine
2
+ module ApplicationHelper
3
+ STATUS_TONES = {
4
+ "pending" => "is-pending", "waiting" => "is-waiting",
5
+ "approved" => "is-approved", "rejected" => "is-rejected",
6
+ "changes_requested" => "is-changes-requested", "cancelled" => "is-cancelled",
7
+ "quarantined" => "is-quarantined"
8
+ }.freeze
9
+
10
+ def status_badge(status)
11
+ tag.span(status.to_s.tr("_", " "), class: "ae-badge #{STATUS_TONES.fetch(status.to_s, "is-pending")}")
12
+ end
13
+
14
+ def actor_label(actor)
15
+ return "—" if actor.nil?
16
+
17
+ name = actor.try(:name) || actor.try(:email) || actor.try(:to_s)
18
+ "#{actor.class.name}##{actor.id} (#{name})"
19
+ end
20
+
21
+ def target_label(target)
22
+ return "—" if target.nil?
23
+
24
+ "#{target.class.name}##{target.id}"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,4 @@
1
+ module ApprovalEngine
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,100 @@
1
+ module ApprovalEngine
2
+ # Relays one outbox event to the outside world. Core ledger state is already
3
+ # settled by the time this runs (transitions advance synchronously), so the
4
+ # job only fires *side-effects*: optional host callbacks and an
5
+ # ActiveSupport::Notifications instrumentation hook.
6
+ #
7
+ # Delivery is *at-least-once* (host callbacks may run more than once on a
8
+ # redelivery, so they MUST be idempotent) and *unordered* (sibling events for
9
+ # one record aren't sequenced — don't assume after_step_approved precedes
10
+ # after_approved). The row is locked for the whole unit of work so two workers
11
+ # can't both deliver it. Retries are bounded; an exhausted event is dead-lettered
12
+ # (failed_at set) rather than retried forever.
13
+ class ProcessOutboxJob < ApplicationJob
14
+ # Read the queue lazily so the host isn't forced onto a queue name we picked.
15
+ queue_as { ApprovalEngine.config.outbox_queue }
16
+
17
+ # Don't depend on the host's adapter happening to retry: back off and retry
18
+ # here. When retries are exhausted, dead-letter the row (drain! then skips it)
19
+ # instead of re-raising into the host queue forever.
20
+ retry_on StandardError, wait: :polynomially_longer, attempts: 8 do |job, error|
21
+ id = job.arguments.first
22
+ OutboxEvent.where(id: id).update_all(failed_at: Time.current, delivery_error: job.class.format_error(error), updated_at: Time.current)
23
+ end
24
+ discard_on ActiveJob::DeserializationError
25
+
26
+ def perform(outbox_event_id)
27
+ # The lock is held for the whole transaction, so a concurrent worker (or a
28
+ # drain! pass) blocks here and then sees `processed` — never double-delivers.
29
+ OutboxEvent.transaction do
30
+ event = OutboxEvent.unprocessed.lock.find_by(id: outbox_event_id)
31
+ next unless event # already processed, or its record was purged
32
+
33
+ deliver(event)
34
+ event.mark_processed!
35
+ end
36
+ rescue => e
37
+ # Record the delivery failure (in its own column, so a retry never clobbers
38
+ # the semantic error_payload the host callback reads) and re-raise to retry.
39
+ OutboxEvent.where(id: outbox_event_id).update_all(delivery_error: self.class.format_error(e), updated_at: Time.current)
40
+ raise
41
+ end
42
+
43
+ private
44
+
45
+ def deliver(event)
46
+ run_host_callbacks(event)
47
+ broadcast_notification(event)
48
+ end
49
+
50
+ # Invoke the matching host callback if the target model chose to define one.
51
+ # The engine never requires these — they are pure convention-over-config.
52
+ def run_host_callbacks(event)
53
+ record = event.record
54
+ target = target_for(record)
55
+ return unless target
56
+
57
+ case event.event_name
58
+ when "step.approved" then try_call(target, :after_step_approved, record)
59
+ when "step.rejected" then try_call(target, :after_step_rejected, record)
60
+ when "step.changes_requested" then try_call(target, :after_step_changes_requested, record)
61
+ when "step.timed_out" then try_call(target, :on_step_timeout, record)
62
+ when "step.expired" then try_call(target, :after_step_expired, record)
63
+ when "step.reassigned" then try_call(target, :after_step_reassigned, record)
64
+ when "approval.approved" then try_call(target, :after_approved)
65
+ when "approval.rejected" then try_call(target, :after_rejected, event.error_payload)
66
+ when "approval.cancelled" then try_call(target, :after_cancelled, event.error_payload)
67
+ when "approval.quarantined" then try_call(target, :on_quarantined, event.error_payload)
68
+ end
69
+ end
70
+
71
+ # Native pub/sub for hosts who prefer subscribers over callbacks:
72
+ # ActiveSupport::Notifications.subscribe("approval_engine.approval.approved") { ... }
73
+ def broadcast_notification(event)
74
+ record = event.record
75
+ ActiveSupport::Notifications.instrument(
76
+ "approval_engine.#{event.event_name}",
77
+ record: record,
78
+ target: target_for(record),
79
+ tenant_id: event.tenant_id
80
+ )
81
+ end
82
+
83
+ def target_for(record)
84
+ case record
85
+ when Approval then record.target
86
+ when Step then record.approval&.target
87
+ end
88
+ end
89
+
90
+ def try_call(target, method_name, *args)
91
+ target.public_send(method_name, *args) if target.respond_to?(method_name)
92
+ end
93
+
94
+ # Class method so the retry-exhausted block (which runs without a job
95
+ # instance receiver) can reuse it.
96
+ def self.format_error(error)
97
+ "#{error.class}: #{error.message}\n#{Array(error.backtrace).first(5).join("\n")}"
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,19 @@
1
+ module ApprovalEngine
2
+ # Periodic safety net that fires the timeout signal for every step whose
3
+ # deadline has passed. Schedule it with whatever recurring mechanism you
4
+ # already run (solid_queue recurring tasks, sidekiq-cron, the `whenever` gem,
5
+ # a Kubernetes CronJob hitting a rake task, ...):
6
+ #
7
+ # ApprovalEngine::TimeoutSweepJob.perform_later # all tenants
8
+ # ApprovalEngine::TimeoutSweepJob.perform_later(tenant_id: account.id)
9
+ #
10
+ # Idempotent: each step times out at most once, so running it more often only
11
+ # makes timeouts fire sooner — it never double-fires.
12
+ class TimeoutSweepJob < ApplicationJob
13
+ queue_as { ApprovalEngine.config.outbox_queue }
14
+
15
+ def perform(tenant_id: nil)
16
+ Step.sweep_timeouts!(tenant_id: tenant_id)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ module ApprovalEngine
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end