Most non-trivial Rails applications eventually grow the same awkward corner: some record that shouldn’t just be created, edited, or deleted. It needs a human to look at it and say “yes” or “no” before anything important happens.
The first time you need this, you add a status column and a few before_save checks. By the third or fourth time, you have duplicated state machines, comments living in random notes text fields, and an audit table that someone built in a hurry and that nobody trusts anymore when finance or legal comes asking questions.
I got tired of writing that code again and again. The result is Signoff, a small, convention-over-configuration gem that adds declarative approval workflows to any ActiveRecord model.
class ExpenseReport < ApplicationRecord
include Signoff
signoff do
state :draft
state :manager_review
state :finance_review
state :approved
state :rejected
transition :draft, to: :manager_review
transition :manager_review, to: :finance_review
transition :finance_review, to: :approved
reject_to :rejected
end
end
You get submit!, approve!, reject!, authorization guards, after_transition hooks for notifications, clean query scopes, and, most importantly, an immutable PostgreSQL JSONB audit trail that actually holds up when someone asks “who approved this and what did they say?”
This post is not the reference manual. It is the list of real situations where the gem has already paid for itself, with the exact DSL and integration patterns I reach for each time.
Why a dedicated gem instead of “just another state column”?
Because the hard parts are never the column. The hard parts are:
- Making sure two people can’t both approve the same thing at the same millisecond.
- Producing a tamper-resistant log that contains who, when, from what IP, with a free-text comment and arbitrary metadata.
- Keeping the “who is allowed to act right now” rules in one obvious place instead of spread across policies, pundit, and controller filters.
- Having
pending?,approved?, andin_state(:finance_review)scopes that stay fast at 500k rows without N+1 or subqueries. - Wiring reliable notifications that only fire after the state and the audit row are durably committed.
Signoff puts all of that behind a tiny DSL and two generators.
Use case 1: Expense reports and reimbursements (the canonical example)
This is the one I ship first on almost every internal tool.
States usually look like:
signoff do
state :draft
state :manager_review
state :finance_review
state :approved
state :rejected
transition :draft, to: :manager_review
transition :manager_review, to: :finance_review
transition :finance_review, to: :approved
reject_to :rejected
allow_transition :manager_review do |user|
user.manager? || user.id == record.submitter_id # allow self-review for small amounts sometimes
end
allow_transition :finance_review do |user, record|
user.finance_team? && record.amount_cents <= user.approval_limit_cents
end
after_transition do |record, event|
ExpenseMailer.with(report: record, event: event).notify_next_step.deliver_later
end
end
In the UI you show different buttons based on can_approve? and can_reject? (they never raise; they just return false when the guard would block).
The audit trail here is gold for reimbursement disputes and for the annual audit. Every approve! call with a comment becomes a row you can never edit or delete by default.
Use case 2: Purchase orders and invoice approvals (multi-stage money movement)
Purchase orders often need more eyes than expense reports. A typical flow I have seen:
draft → procurement_review → budget_owner_approval → finance_release → ordered (or paid)
With Signoff you model the “approval phase” cleanly even if the rest of the PO lifecycle (supplier sync, receiving, etc.) lives in other states or separate models.
signoff do
state :draft
state :procurement_review
state :budget_owner_review
state :finance_release
state :ordered
state :rejected
transition :draft, to: :procurement_review
transition :procurement_review, to: :budget_owner_review
transition :budget_owner_review, to: :finance_release
transition :finance_release, to: :ordered
reject_to :rejected
allow_transition :procurement_review do |user| user.procurement? end
allow_transition :budget_owner_review do |user, po| user.budget_owner_for?(po.department) end
allow_transition :finance_release do |user| user.can_release_funds? end
end
The metadata field on events is perfect here. On the finance_release step you can do:
po.approve!(
user: current_user,
comment: "Released against Q3 marketing budget",
metadata: {
approved_amount_cents: po.total_cents,
budget_line: "marketing-q3-2026",
po_number_assigned: "PO-2026-0842"
}
)
Later you can query across events:
Signoff::Event
.where(action: "approve", to_state: "finance_release")
.where("metadata->>'budget_line' = ?", "marketing-q3-2026")
All without adding columns to the purchase_orders table.
Use case 3: HR leave, time-off, and PTO requests
HR workflows are full of nuance: manager approval, sometimes HRBP review for long leave, automatic calendar block only on final approval, and a very strong requirement for an audit log (“we need to prove this person was approved for those dates”).
The DSL becomes almost identical to expenses:
signoff do
state :requested
state :manager_approval
state :hr_review
state :approved
state :rejected
transition :requested, to: :manager_approval
transition :manager_approval, to: :hr_review
transition :hr_review, to: :approved
reject_to :rejected
end
Because the state lives in a plain column on the time_off_requests table, you can still use all your normal Rails associations and scopes for “show me everyone’s approved leave this month” while the approval flow itself is handled by the gem.
The last_approval and approved_by helpers make it trivial to render “Approved by Priya Sharma on June 12” everywhere the request is displayed.
Use case 4: Content publishing and document approval pipelines
Marketing sites, internal knowledge bases, and regulated documents (policies, contracts, security reviews) often need an editorial + legal + final-publish flow.
One pattern that works well:
signoff do
state :draft
state :editorial_review
state :legal_review
state :approved_for_publish
state :published
state :rejected
transition :draft, to: :editorial_review
transition :editorial_review, to: :legal_review
transition :legal_review, to: :approved_for_publish
transition :approved_for_publish, to: :published
reject_to :rejected
after_transition do |doc, event|
case event.to_state.to_s
when "legal_review"
LegalReviewJob.perform_later(doc.id)
when "published"
doc.publish! # your own method that actually flips a public boolean or copies to CDN
end
end
end
Notice that “published” is a workflow terminal state. You can still have a separate published_at or visibility column on the model if you want; the workflow state just tells you how it got there.
For documents that can be “scheduled”, some teams add an extra state or treat the final approve! as the trigger that sets a publish job for later.
Use case 5: Ops and change-control sign-offs
Internal platforms that manage production changes (feature flag flips, database migrations that need DBA sign-off, firewall rule changes, access grants) are another sweet spot.
These flows tend to value two things above all:
- A bulletproof record of who approved what and when.
- The ability to require different roles at different stages (on-call engineer vs. security vs. VP).
signoff do
state :proposed
state :oncall_review
state :security_review
state :exec_approval
state :executed
state :rolled_back
state :rejected
transition :proposed, to: :oncall_review
transition :oncall_review, to: :security_review
transition :security_review, to: :exec_approval
transition :exec_approval, to: :executed
reject_to :rejected
end
Because the gem takes a row-level lock (SELECT ... FOR UPDATE) and re-validates the current state inside the transition transaction, you get safe concurrency even when two on-call engineers are looking at the same change request.
Use case 6: Support ticket escalation and resolution sign-off
High-severity or customer-impacting tickets sometimes require a second pair of eyes before they are closed.
signoff do
state :open
state :in_progress
state :awaiting_signoff
state :resolved
state :rejected # reopened or needs more work
transition :open, to: :in_progress
transition :in_progress, to: :awaiting_signoff
transition :awaiting_signoff, to: :resolved
reject_to :rejected # "needs more work" path
end
The comment on the final approve event becomes the official resolution note. The entire thread of decisions is queryable without having to reconstruct it from ticket comments or activity feeds.
What the audit trail actually buys you
The signoff_events table (JSONB + GIN index on metadata) is deliberately boring and append-only. That is the point.
Every event records:
action(“submit”, “approve”, “reject”)from_state/to_stateuser_idcommentmetadata(jsonb)ip_addressanduser_agent(when you turn the config on)created_at
Because it is polymorphic (workflowable), the same events table serves every model in your app that includes Signoff.
Common queries I actually write:
- “Show me every approval that touched the marketing budget line this quarter”
- “Who has the most rejections on expense reports in the last 90 days?”
- “List all records currently waiting on finance_review for department X”
The scopes on the model (pending, approved, in_state) stay fast because the current state lives in an indexed column on the owning table, not in the events table.
A few honest limitations
Signoff is deliberately opinionated:
- It wants PostgreSQL (the audit trail is a jsonb column + GIN index).
- It is designed for linear-to-slightly-branching human approval flows. Extremely graph-like or parallel approval processes are outside its sweet spot.
- It gives you one “reject sink” state by design (
reject_to). Most real processes only need one place that means “this was denied or sent back.”
If your workflow is closer to a full BPMN engine or requires arbitrary parallel branches and complex and/or conditions, you will probably outgrow it. For the 80% of cases where “a few humans need to look at this thing in sequence and we must never lose the record of who said what,” it has been extremely effective.
How to include it in your Rails app
The generators get you most of the way there in under a minute.
bundle add signoff
rails generate signoff:install # creates the events table + initializer
rails generate signoff:model ExpenseReport
rails db:migrate
Then wire up one model (the DSL parts are shown in the use cases above):
# app/models/expense_report.rb
class ExpenseReport < ApplicationRecord
include Signoff
signoff do
# ... your states, transitions, allow_transition, after_transition ...
end
end
Automatic acting user (recommended)
Drop this into ApplicationController once. It populates Signoff::Current.user (and optionally IP/user-agent) from your existing current_user so you rarely have to pass user: manually:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Signoff::Controller
end
Routes
# config/routes.rb
resources :expense_reports do
member do
patch :submit
patch :approve
patch :reject
end
end
Controller actions + error handling
# app/controllers/expense_reports_controller.rb
class ExpenseReportsController < ApplicationController
before_action :set_report, only: %i[show submit approve reject]
rescue_from Signoff::UnauthorizedError,
Signoff::InvalidTransitionError do |error|
redirect_back fallback_location: expense_reports_path, alert: error.message
end
def submit
@report.submit!(comment: params[:comment])
redirect_to @report, notice: "Submitted for review."
end
def approve
@report.approve!(comment: params[:comment]) # user comes from Signoff::Current
redirect_to @report, notice: "Approved."
end
def reject
@report.reject!(comment: params[:comment])
redirect_to @report, notice: "Rejected."
end
private
def set_report
@report = ExpenseReport.find(params[:id])
end
end
Views (the important bits)
<%# app/views/expense_reports/show.html.erb %>
<p>
Status: <strong><%= @report.current_state.to_s.humanize %></strong>
<% if @report.approved? %>✅<% elsif @report.rejected? %>❌<% end %>
</p>
<% if @report.draft? %>
<%= button_to "Submit for review", submit_expense_report_path(@report), method: :patch %>
<% end %>
<% if @report.can_approve?(current_user) %>
<%= button_to "Approve", approve_expense_report_path(@report), method: :patch %>
<% end %>
<% if @report.can_reject?(current_user) %>
<%= button_to "Reject", reject_expense_report_path(@report), method: :patch %>
<% end %>
<h3>Audit trail</h3>
<% @report.workflow_history.each do |event| %>
<div>
<%= event.created_at.to_fs(:short) %> —
<strong><%= event.action %></strong>
<%= event.from_state %> → <%= event.to_state %>
by <%= event.user&.name || "system" %>
<% if event.comment.present? %> — <%= event.comment %><% end %>
</div>
<% end %>
You can also be explicit and pass the user directly:
@report.approve!(user: current_user, comment: "Approved after checking receipts")
Notifications with ActiveJob + ActionMailer
The cleanest place for side effects is inside the signoff block using after_transition. It runs after the database transaction commits, so the Signoff::Event is already persisted when your job runs.
# in the model
signoff do
# ... states and transitions ...
after_transition do |record, event|
WorkflowNotificationJob.perform_later(record.id, event.id)
end
end
# app/jobs/workflow_notification_job.rb
class WorkflowNotificationJob < ApplicationJob
queue_as :default
def perform(record_id, event_id)
event = Signoff::Event.find(event_id)
record = event.workflowable
case event.action
when "submit"
ApprovalMailer.with(report: record, event: event).submitted.deliver_later
when "approve"
ApprovalMailer.with(report: record, event: event).advanced.deliver_later
when "reject"
ApprovalMailer.with(report: record, event: event).rejected.deliver_later
end
end
end
# app/mailers/approval_mailer.rb
class ApprovalMailer < ApplicationMailer
def submitted
@report = params[:report]
mail(to: User.managers.pluck(:email),
subject: "New item awaiting your review")
end
def advanced
@report = params[:report]
mail(to: User.finance_team.pluck(:email),
subject: "Ready for the next approval step")
end
def rejected
@report = params[:report]
@event = params[:event]
mail(to: @report.submitter.email,
subject: "Your request was rejected")
end
end
Dashboard / listings using the built-in scopes
One of the nicest parts of Signoff is the scopes it adds. You get pending, approved, rejected, and in_state(...) for free, and they are backed by an indexed column so they stay fast.
Expand the controller with an index:
# app/controllers/expense_reports_controller.rb
def index
@pending = ExpenseReport.pending.order(created_at: :desc)
@approved = ExpenseReport.approved.limit(20)
@rejected = ExpenseReport.rejected
end
And a simple dashboard view:
<%# app/views/expense_reports/index.html.erb %>
<h2>Awaiting action (<%= @pending.size %>)</h2>
<%= render partial: "expense_report", collection: @pending %>
<h2>Recently approved</h2>
<%= render partial: "expense_report", collection: @approved %>
<h2>Rejected</h2>
<%= render partial: "expense_report", collection: @rejected %>
You can chain them like any other scope:
ExpenseReport.where(department: "marketing").pending
ExpenseReport.in_state(:finance_review, :manager_review)
Preload the audit trail when you need it to avoid N+1s:
ExpenseReport.includes(signoff_events: :user).find(params[:id])
The repo contains a complete runnable example under examples/expense_approval/ (full controller, views, job, mailer, seeds, and a demo script) if you want to see everything wired together in one place.
Where to go from here
- GitHub: https://github.com/JijoBose/Signoff
- RubyGems: https://rubygems.org/gems/signoff
If you’ve built approval flows by hand (or with AASM, state_machines, or an external service) and have war stories about the audit table that always ends up lying or the race condition that let two people approve the same PO, I’d love to hear them.
The gem is still young (0.1.0 as of this writing). The core loop (declare states, drive transitions, trust the log) has already removed a surprising amount of repetitive and error-prone code from the projects where I’ve used it.
If any of the situations above sound like problems you are solving right now, give it a try. The generators get you to a working flow in minutes; the hard part is usually just deciding what the states should actually be called.