Back to blog
RubyRailsOpen SourceArchitecturePostgreSQL

Approvals Are a Mechanism, Not a Feature: Building ApprovalEngine

June 18, 20265 min read
Share:

Why I built a Rails engine for approvals from first principles - an immutable ledger, consensus that isn't a veto, timeouts that never forge a yes, and a transactional outbox that can't lie.

Every company I've worked at has rebuilt "approvals" from scratch. Every single one did it badly. I finally got annoyed enough to build it once, properly, and put it on RubyGems.

The problem hiding in every B2B app

At some point your app grows a sentence like this: "a manager has to approve expenses over ₹10,000, unless it's travel, in which case Finance also signs off, and if it sits for two days it should escalate."

So someone adds a status column. Then an approved_by_id. Then a before_save callback with three ifs. Six months later it's a 400-line ApprovalsService that nobody understands, there's no audit trail anyone trusts, two people can approve the same thing in a race, and "who approved this and why" is answered by reading updated_at and guessing.

Approvals feel like a feature. They're not. They're a mechanism - the same shape in every domain: someone proposes, some set of people decide, the decision is recorded, side-effects fire. The policy (who, when, what the thresholds are) changes per company. The mechanism never does.

So I built the mechanism and left the policy to you. That single distinction - borrowed shamelessly from DHH's "mechanism, not policy" instinct - drove every decision in ApprovalEngine.

The ledger: Approval → Track → Step

Everything is an append-only ledger of three nested records:

  • Approval - one run. Your Invoice fans out into one or more parallel Tracks.
  • Track - one parallel path (Finance, Legal, Logistics…), each a sequence of layers.
  • Step - one person's slot. approve!, reject!, request_changes!.

It's append-only on purpose. You never edit history - requesting changes opens a fresh iteration instead of mutating the old one. When an auditor asks "what happened here," the answer is the table, not a reconstruction.

class Invoice < ApplicationRecord has_approvals exposes_for_approval do attribute :amount, type: :decimal attribute :department, type: :string end def after_approved = PaymentService.disburse!(self) end invoice.run_approval!(event: "invoice.created")

That's the whole integration surface. Routing rules live in the database as JSON Logic, scoped per tenant, so Ops can change "over ₹10,000" without a deploy.

Three decisions I'm proud of

1. Rejection isn't a veto

Naively, one "no" kills the approval. But if your rule is "any 2 of 5 reviewers," one person rejecting shouldn't sink it while three others can still say yes. So rejection is consensus-aware: a layer fails only once the required approvals become unreachable - not on the first no. The same :any / :all / :majority / "60%" / N vocabulary that decides approval also decides failure. One concept, used at the layer level and across parallel tracks ("2 of 3 departments must sign off").

2. The engine never auto-approves. Ever.

This is the one place the gem is deliberately opinionated. A step can time out - but a timeout never becomes a yes. It can deny (expire!), escalate, or remind, but it will not forge approval. "Approved by Bob because he was on vacation" is a corrupt record, and corruption is fatal for a system whose entire value is trustworthiness. Silence is not consent. If you want auto-approval, that's your policy, in your model - the engine won't do it behind your back.

3. A side-effect can never roll back an approval

When an approval completes, you want to disburse funds, email people, hit three APIs. If the payment gateway is down, should the approval un-happen? Obviously not. So state transitions commit instantly and synchronously, and every side-effect is written to a transactional outbox in the same transaction, then relayed asynchronously. A down mailer can't roll back a decision; a crashed worker can't silently drop one. Delivery is at-least-once (make your callbacks idempotent) and now dead-lettered, so a permanently-failing callback can't retry forever.

Concurrency is handled by locking the aggregate root (the Approval) around every transition - two people clicking "Approve" on the last needed step can't both win.

What I deliberately left out

The hardest part of a library like this is saying no. ApprovalEngine does not decide who your approvers are, what "late" means, how to send notifications, or what your record's status column should say. It hands you the seams - a tenant resolver, an actor resolver, lifecycle callbacks, notification events - and stays out of your domain. That restraint is the feature.

Shipped

It's live: gem install approval_engine. Postgres-backed, Rails ≥ 7, MIT-licensed, with a mountable read-only ops dashboard, a generator, and a cookbook of copy-paste recipes.

It went through two rounds of brutal self-review before 1.0 - completing the lifecycle (cancel! to withdraw, reassign! to escalate), hardening the outbox, and ruthlessly deleting anything speculative. The result is something I'd actually drop into a production app without flinching.

If you've ever rebuilt approvals from scratch - and you have - give it a look. The next time that sentence shows up in a spec, you won't have to write the 400-line service.

Harshit Chaudhary

Written by

Harshit Chaudhary

Backend Software Engineer at BrowserStack, architecting AI accessibility agents covering 40+ WCAG criteria across web, mobile, and design. Building AI accessibility agents at BrowserStack.