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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +138 -0
- data/MIT-LICENSE +20 -0
- data/README.md +249 -0
- data/app/controllers/approval_engine/application_controller.rb +10 -0
- data/app/controllers/approval_engine/approvals_controller.rb +29 -0
- data/app/helpers/approval_engine/application_helper.rb +27 -0
- data/app/jobs/approval_engine/application_job.rb +4 -0
- data/app/jobs/approval_engine/process_outbox_job.rb +100 -0
- data/app/jobs/approval_engine/timeout_sweep_job.rb +19 -0
- data/app/models/approval_engine/application_record.rb +5 -0
- data/app/models/approval_engine/approval.rb +144 -0
- data/app/models/approval_engine/approval_plan.rb +60 -0
- data/app/models/approval_engine/audit_log.rb +28 -0
- data/app/models/approval_engine/consensus.rb +37 -0
- data/app/models/approval_engine/delegation.rb +34 -0
- data/app/models/approval_engine/history.rb +62 -0
- data/app/models/approval_engine/outbox_event.rb +47 -0
- data/app/models/approval_engine/step.rb +271 -0
- data/app/models/approval_engine/template_step.rb +22 -0
- data/app/models/approval_engine/track.rb +127 -0
- data/app/models/approval_engine/track_template.rb +23 -0
- data/app/models/approval_engine/trigger_rule.rb +17 -0
- data/app/models/concerns/approval_engine/approvable.rb +183 -0
- data/app/services/approval_engine/approval_builder.rb +172 -0
- data/app/services/approval_engine/iteration_builder.rb +44 -0
- data/app/services/approval_engine/rule_evaluator.rb +119 -0
- data/app/views/approval_engine/approvals/index.html.erb +38 -0
- data/app/views/approval_engine/approvals/show.html.erb +55 -0
- data/app/views/layouts/approval_engine/dashboard.html.erb +49 -0
- data/config/routes.rb +8 -0
- data/db/migrate/20260614103434_create_approval_engine_core_tables.rb +107 -0
- data/db/migrate/20260614103435_create_approval_engine_infrastructure_tables.rb +35 -0
- data/db/migrate/20260614104809_create_approval_engine_blueprint_tables.rb +60 -0
- data/db/migrate/20260616000001_add_trigger_rule_to_approvals.rb +14 -0
- data/db/migrate/20260617000001_add_approvals_required_to_approvals.rb +11 -0
- data/db/migrate/20260617000002_add_delivery_tracking_to_outbox_events.rb +11 -0
- data/lib/approval_engine/approval_exposure.rb +66 -0
- data/lib/approval_engine/configuration.rb +67 -0
- data/lib/approval_engine/engine.rb +17 -0
- data/lib/approval_engine/model_extensions.rb +20 -0
- data/lib/approval_engine/version.rb +3 -0
- data/lib/approval_engine.rb +12 -0
- data/lib/generators/approval_engine/install/install_generator.rb +34 -0
- data/lib/generators/approval_engine/install/templates/POST_INSTALL +55 -0
- data/lib/generators/approval_engine/install/templates/approval_engine.rb +21 -0
- data/lib/generators/approval_engine/views/templates/approvals/index.html.erb +17 -0
- data/lib/generators/approval_engine/views/templates/approvals_controller.rb +32 -0
- data/lib/generators/approval_engine/views/views_generator.rb +33 -0
- 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
|
+
[](https://github.com/Harry-kp/approval_engine/actions/workflows/ci.yml)
|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
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,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
|