ApprovalEngine
Multi-tenant, immutable-ledger human approval flows for Rails.
Use it when a manager approves an invoice, then a CFO. Or Legal and IT in parallel. Or "any two of five reviewers." ApprovalEngine supplies the generic machinery: an append-only ledger, race-safe transitions, runtime routing rules, and async side-effects. You decide what gets approved, who approves, and what happens next.
Is this for you?
Use it when you have:
- Multi-step, human-in-the-loop approvals, sequential or parallel
- Routing rules that admins change at runtime, without a deploy
- A need to audit who approved what, when, and on whose behalf
- Concurrency that must never double-approve
Look elsewhere when:
- You just need a boolean
approvedflag. A column and a method are simpler. - You need a state machine for non-approval domains. Try AASM or state_machines.
- You're not on PostgreSQL. The routing engine needs
jsonbandgin.
Installation
Add this line to your application's Gemfile:
gem "approval_engine"
And then execute:
bundle install
rails generate approval_engine:install
rails db:migrate
The generator copies migrations and an initializer, and prints next steps.
Quickstart
Teach your actor class to resolve approval groups. The engine creates one
step per returned record. target is the record being approved (e.g. the
Invoice); this example ignores it, but you can use it for record-scoped
groups like "this invoice's department head".
class User < ApplicationRecord
def self.resolve_approval_group(group_name, target)
where(role: group_name) # `target` available for record-scoped resolution
end
end
Arm a model and declare the attributes the rules engine may read.
class Invoice < ApplicationRecord
has_approvals
exposes_for_approval do
attribute :amount, type: :decimal
end
def after_approved
PaymentService.disburse_funds!(self)
end
end
Define a template, its ordered steps, and the rule that triggers it.
template = ApprovalEngine::TrackTemplate.create!(
tenant_id: "acme", name: "High-value invoice", status: "active"
)
template.template_steps.create!(name: "Manager", layer: 1, assigned_group: "manager")
template.template_steps.create!(name: "CFO", layer: 2, assigned_group: "cfo")
template.trigger_rules.create!(
tenant_id: "acme", event_name: "invoice.created",
condition: { ">" => [{ "var" => "amount" }, 10_000] }
)
Trigger a run.
invoice = Invoice.create!(amount: 20_000)
invoice.run_approval!(event: "invoice.created", tenant_id: "acme")
Verify it routed before going further.
invoice.preview_approval(event: "invoice.created", tenant_id: "acme").triggered?
# => true
Act on a step. actionable_by is the approver's inbox, including delegations.
ApprovalEngine::Step.actionable_by(current_user).first.approve!(by: current_user)
Gotchas
This gem fails closed and silent when misconfigured. If a run doesn't
trigger, preview_approval(...).triggered? tells you why. Check:
- The rule's
event_namematches the event you fire. - The template
statusis"active". Draft templates never fire. - Every attribute a rule reads is declared in
exposes_for_approval. config.current_tenant_methodis set. Until then, auto-routing on create is a no-op, so passtenant_id:explicitly.
See it live
Run the demo against a clone of this repo, not your own app. It needs PostgreSQL running.
bin/demo
# seeds sample data and boots the dashboard at
# http://localhost:3000/approval_engine
Or explore the API in a console preloaded with sample data.
bin/console
>> Rails.application.load_seed
>> ApprovalEngine::Step.pending.first.approve!(by: User.find_by(role: "manager"))
The mounted dashboard lists every approval, filters by status, and drills
into tracks, steps, and the full audit trail. It is read-only, with
bundled styling. It has no auth of its own — when you mount it in a real app,
wrap it in a constraints/authenticated route (recipe).
Configuration
# config/initializers/approval_engine.rb
ApprovalEngine.configure do |config|
config.actor_class = "User" # who approves
config.current_tenant_method = -> { Current.account } # anything with #id
config.outbox_queue = :default # ActiveJob queue for side-effects
config.raise_on_rule_errors = false # fail closed in production
end
current_tenant_method defaults to nil. While it is nil, auto-routing
on create silently no-ops, since the engine cannot scope the rules.
Single-tenant apps can return a constant, e.g.
-> { Struct.new(:id).new("default") }.
No Redis or Sidekiq required. Side-effects run through ActiveJob, so SolidQueue, Sidekiq, or the async adapter all work.
Core concepts
| Term | What it is |
|---|---|
| Template | The reusable blueprint: ordered layers of steps with consensus rules |
| Trigger rule | A tenant-scoped JSON Logic condition that selects a template for an event |
| Approval | One run: a host record fanned out into one or more parallel tracks |
| Track | One parallel path of layered steps within an approval |
| Step | One approval slot in the immutable ledger (approve! / reject! / request_changes!) |
| Consensus | How many approvals a layer needs: approvals_required — :any, :all, :majority, a percentage like "60%", or a count |
Every run is Approval -> Track -> Step, even the one-approver case.
A single-track run is an approval with one track, not a special path.
You never build that chain by hand: start a run with
run_approval! and act on a step with step.approve!. The
layers surface only when you need them, such as parallel tracks or the
dashboard. For a single-track approval, approval.track and
approval.step read it back without .first.
approvals_required is one idea used at two levels: within a layer (how many of
its steps), and across the parallel tracks of a scatter-gather (how many tracks
must approve — :all by default). Beyond the happy path, an approval can be
withdrawn (approval.cancel!) and a stuck step escalated (step.reassign!).
Reacting to outcomes — define any of these on your model and the engine
calls them (via the outbox, at-least-once and unordered): after_approved,
after_rejected(reason), after_cancelled(reason), on_quarantined(reason),
after_step_approved/rejected/changes_requested/expired/reassigned(step),
on_step_timeout(step). Or subscribe to the matching approval_engine.*
notifications.
Cookbook
See docs/COOKBOOK.md for copy-paste recipes covering every supported case, from "any two of five reviewers" to "Legal and IT in parallel" to delegation and requesting changes.
How it works
| Concern | Mechanism |
|---|---|
| Auditability | Append-only Step ledger; requesting changes appends an iteration instead of editing history |
| Concurrency | Approval-scoped pessimistic lock around every transition, so no double-approvals |
| Routing | JSON Logic ASTs in jsonb, evaluated by shiny_json_logic |
| Side-effects | Transactional outbox relayed by ActiveJob, so a down API never rolls back an approval |
| Safety | A malformed rule quarantines the approval instead of raising |
A missing attribute is a clean non-match, since JSON Logic treats it as
false, so the approval just doesn’t start. Only a malformed rule, such
as an unknown operator, quarantines. The approval never crashes either
way. Set config.raise_on_rule_errors = true to surface errors loudly.
For the full design — the model hierarchy, the consensus/rework model, and why the outbox exists — see docs/ARCHITECTURE.md.
Development
ApprovalEngine needs Ruby 3.1+ and PostgreSQL.
bin/setup
bin/rails app:test
bundle exec rubocop
Point at any Postgres with DATABASE_URL if you're not on the default
socket. See CONTRIBUTING.md for the full guide.
Contributing
Bug reports and pull approvals are welcome on GitHub at https://github.com/Harry-kp/approval_engine. Please read CONTRIBUTING.md and our Code of Conduct.
License
Available as open source under the terms of the MIT License.