Signoff
Concurrency-safe approval workflows for ActiveRecord with an immutable audit trail โ a drop-in model concern, convention over configuration.
Declare states and transitions with a tiny DSL, get submit! / approve! /
reject! for free, plug in authorization and notifications, and keep an immutable
PostgreSQL audit trail of every decision โ no external services required.
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
report.submit!
report.approve!(user: current_user, comment: "Looks good")
report.reject!(user: current_user, comment: "Missing receipts")
report.current_state # => :finance_review
report.workflow_history # => [#<Signoff::Event ...>, ...]
Features
- ๐งฉ Declarative DSL โ states, transitions, reject paths, guards and callbacks.
- โ๏ธ Convention over configuration โ sensible defaults, minimal setup.
- ๐ PostgreSQL-first โ an immutable JSONB audit log, properly indexed for millions of rows.
- ๐ Authorization hooks โ
allow_transitionguards keyed to the state being acted on. - ๐ฃ Notifications โ
after_transitioncallbacks that play nicely with ActiveJob & ActionMailer. - ๐งพ Immutable audit trail โ who did what, when, from where, with comments and metadata.
- ๐ Query scopes โ
approved,pending,rejected,in_state(:x). - ๐๏ธ Rails generators โ install + per-model migrations.
- ๐ Rails 7.x & 8.x, Ruby 3.2+.
Table of Contents
- Requirements
- Installation
- Quick Start
- Using It in a Rails Application
- The DSL
- Instance API
- Query Scopes
- Authorization
- Audit Trail
- Notifications
- Configuration
- Performance & Scale
- Generators
- Example App
- Testing
- Troubleshooting
- Development
- Contributing
- License
Requirements
- Ruby >= 3.2
- Rails 7.1 โ 8.x (
activerecord,activesupport,railties) - PostgreSQL (the audit trail uses a
jsonbcolumn and a GIN index)
CI verifies every combination of Ruby 3.2 / 3.3 / 3.4 against Rails 7.1, 7.2, 8.0 and 8.1.
Installation
Add the gem to your Gemfile:
gem "signoff"
Then:
bundle install
Run the installer to create the audit-events migration and the initializer:
rails generate signoff:install
rails db:migrate
Add a state column to each model that has a workflow:
rails generate signoff:model ExpenseReport
rails db:migrate
The current state is stored in a column on the model itself (default
approval_state), which keeps state queries fast and indexable. The events table is a separate, immutable audit log.
Quick Start
1. Declare the workflow
class ExpenseReport < ApplicationRecord
include Signoff
signoff do
state :draft
state :manager_review
state :finance_review
state :approved
state :rejected
initial_state :draft # optional; defaults to the first state
transition :draft, to: :manager_review
transition :manager_review, to: :finance_review
transition :finance_review, to: :approved
reject_to :rejected
# Only managers can act on a report that is in manager_review:
allow_transition :manager_review do |user|
user.manager?
end
allow_transition :finance_review do |user|
user.finance_team?
end
# Fire a notification after each successful transition:
after_transition do |record, event|
WorkflowNotificationJob.perform_later(record.id, event.id)
end
end
end
2. Drive it from your application
report = ExpenseReport.create!(title: "Conference travel", amount: 1_200)
report.current_state # => :draft
report.pending? # => true
report.submit! # draft -> manager_review
report.approve!(
user: current_user, # must satisfy the manager_review guard
comment: "Looks good"
) # manager_review -> finance_review
report.approve!(user: finance_lead) # finance_review -> approved
report.approved? # => true
report.workflow_history # chronological audit trail
report.approved_by # => #<User finance_lead>
3. Reject when needed
report.reject!(user: current_user, comment: "Missing receipts")
report.rejected? # => true
report.last_rejection.comment # => "Missing receipts"
Using It in a Rails Application
A complete, idiomatic integration โ wiring the acting user, routes, a
controller, views, and notifications. (A runnable version of this lives in
examples/expense_approval/.)
1. The model
# app/models/expense_report.rb
class ExpenseReport < ApplicationRecord
include Signoff
belongs_to :submitter, class_name: "User"
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?
end
allow_transition :finance_review do |user|
user.finance_team?
end
# Runs after the transaction commits, with the persisted event.
after_transition do |record, event|
WorkflowNotificationJob.perform_later(record.id, event.id)
end
end
end
Generate the audit table and the model's state column once:
rails generate signoff:install
rails generate signoff:model ExpenseReport
rails db:migrate
2. Attribute the acting user automatically
Include the controller concern in ApplicationController. It populates
Signoff::Current from your current_user helper (and the request IP /
user agent when those are enabled in the initializer), so transitions are
attributed without passing user: everywhere.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Signoff::Controller # sets Current.user / ip_address / user_agent
# Assumes a `current_user` helper (Devise, custom auth, etc.)
end
Prefer to be explicit, or not using the concern? Just pass the user directly:
report.approve!(user: current_user, comment: "...").
3. Routes
# config/routes.rb
Rails.application.routes.draw do
resources :expense_reports do
member do
patch :submit
patch :approve
patch :reject
end
end
end
4. Controller
# app/controllers/expense_reports_controller.rb
class ExpenseReportsController < ApplicationController
before_action :set_report, only: %i[show submit approve reject]
# Turn workflow errors into friendly responses instead of 500s.
rescue_from Signoff::UnauthorizedError do |error|
redirect_back fallback_location: expense_reports_path, alert: error.
end
rescue_from Signoff::InvalidTransitionError do |error|
redirect_back fallback_location: expense_reports_path, alert: error.
end
# Use the generated scopes for dashboards.
def index
@pending = ExpenseReport.pending.order(created_at: :desc)
@approved = ExpenseReport.approved
@rejected = ExpenseReport.rejected
end
# Preload the audit trail (and the acting users) to avoid N+1 queries.
def show
@report = ExpenseReport.includes(signoff_events: :user).find(params[:id])
@history = @report.workflow_history
end
def submit
@report.submit!(comment: transition_params[:comment])
redirect_to @report, notice: "Submitted for review."
end
def approve
# The acting user comes from current_user via Signoff::Controller.
@report.approve!(comment: transition_params[:comment])
redirect_to @report, notice: "Approved."
end
def reject
@report.reject!(comment: transition_params[:comment])
redirect_to @report, notice: "Rejected."
end
private
def set_report
@report = ExpenseReport.find(params[:id])
end
def transition_params
params.permit(:comment)
end
end
5. Views
Render the current state and only the actions the current user is actually
allowed to perform (can_approve? / can_reject? never raise):
<%# app/views/expense_reports/show.html.erb %>
<h1><%= @report.title %></h1>
<p>
Status:
<span class="badge badge-<%= @report.current_state %>">
<%= @report.current_state.to_s.humanize %>
</span>
</p>
<%= form_with url: nil do %>
<%# Show "Submit" only while the report is a draft %>
<% 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 %>
<% end %>
<h2>Audit trail</h2>
<table>
<thead>
<tr><th>When</th><th>Action</th><th>Transition</th><th>By</th><th>Comment</th></tr>
</thead>
<tbody>
<% @history.each do |event| %>
<tr>
<td><%= event.created_at.to_fs(:short) %></td>
<td><%= event.action.humanize %></td>
<td><%= event.from_state %> → <%= event.to_state %></td>
<td><%= event.user&.name || "system" %></td>
<td><%= event.comment %></td>
</tr>
<% end %>
</tbody>
</table>
A dashboard built from the scopes:
<%# app/views/expense_reports/index.html.erb %>
<h2>Awaiting a decision (<%= @pending.size %>)</h2>
<%= render @pending %>
<h2>Approved (<%= @approved.size %>)</h2>
<%= render @approved %>
<h2>Rejected (<%= @rejected.size %>)</h2>
<%= render @rejected %>
6. Notifications (ActiveJob + ActionMailer)
The model's after_transition hook enqueues this job after each transition
commits, so the event row is guaranteed to exist when the job runs:
# app/jobs/workflow_notification_job.rb
class WorkflowNotificationJob < ApplicationJob
queue_as :default
def perform(record_id, event_id)
event = Signoff::Event.find(event_id)
report = event.workflowable
case event.action
when "submit" then ApprovalMailer.with(report: report, event: event).submitted.deliver_later
when "approve" then ApprovalMailer.with(report: report, event: event).advanced.deliver_later
when "reject" then ApprovalMailer.with(report: report, 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: "Expense report awaiting your review")
end
def advanced
@report = params[:report]
mail(to: User.finance_team.pluck(:email), subject: "Expense report ready for finance review")
end
def rejected
@report = params[:report]
@event = params[:event]
mail(to: @report.submitter.email, subject: "Your expense report was rejected")
end
end
7. Testing the integration
# spec/requests/expense_report_approvals_spec.rb
RSpec.describe "Expense report approvals", type: :request do
it "lets a manager approve a submitted report" do
report = ExpenseReport.create!(title: "Travel", amount: 500, submitter: create(:user))
report.submit!
sign_in create(:user, manager: true)
patch approve_expense_report_path(report), params: { comment: "Looks good" }
expect(report.reload.current_state).to eq(:finance_review)
expect(report.last_approval.comment).to eq("Looks good")
end
it "blocks an employee and surfaces the error" do
report = ExpenseReport.create!(title: "Travel", amount: 500, submitter: create(:user))
report.submit!
sign_in create(:user) # not a manager
patch approve_expense_report_path(report), params: { comment: "nope" }
expect(response).to redirect_to(expense_reports_path)
expect(report.reload.current_state).to eq(:manager_review)
end
end
The DSL
Everything goes inside signoff do ... end. Pass column: to override
the state column for a single model (e.g. signoff(column: :workflow_state) do).
| DSL method | Description |
|---|---|
state(name, initial: false) |
Declare a state. initial: true marks the start state. |
states(*names) |
Declare several states at once. |
initial_state(name) |
Set the start state explicitly (defaults to the first declared). |
transition(from, to:) |
Declare a forward transition. to: accepts a symbol or an array. |
reject_to(state) |
The state reject! moves a record into. |
| `allow_transition(from) { \ | user[, record]\ |
| `before_transition { \ | record, from, to\ |
| `after_transition { \ | record, event\ |
Definitions are validated when the class loads. You'll get a descriptive
Signoff::DefinitionError for duplicate states, transitions to/from
undeclared states, a missing initial state, or an undeclared reject state.
Instance API
Including Signoff and declaring a workflow generates:
Transitions
report.submit!(user: nil, comment: nil, metadata: {}, to: nil)
report.approve!(user: nil, comment: nil, metadata: {}, to: nil)
report.reject!(user: nil, comment: nil, metadata: {})
submit!andapprove!both advance the single forward transition from the current state; they differ only in the auditactionrecorded ("submit"vs"approve"). Usesubmit!for the first step out ofdraftby convention.- When a state has more than one forward transition, pass
to:to disambiguate (report.approve!(to: :finance_review)). reject!moves the record to thereject_tostate.- All accept
ip_address:anduser_agent:keyword overrides (see Configuration). - Each returns the created
Signoff::Event.
State & predicates
report.current_state # => :manager_review (a Symbol)
report.pending? # not approved and not rejected
report.approved? # in a successful terminal state
report.rejected? # in the reject state
# One predicate per declared state:
report.draft?
report.manager_review?
report.finance_review?
Authorization predicates
report.can_approve?(current_user) # => true / false (never raises)
report.can_reject?(current_user) # => true / false
Both fall back to Signoff::Current.user when no argument is given.
Audit helpers
report.workflow_history # all events, chronological, preloadable
report.last_approval # most recent "approve" event
report.last_rejection # most recent "reject" event
report.approved_by # the user who moved it into a terminal "approved" state
Query Scopes
ExpenseReport.approved # in a successful terminal state
ExpenseReport.pending # neither approved nor rejected
ExpenseReport.rejected # in the reject state
ExpenseReport.in_state(:finance_review)
ExpenseReport.in_state(:draft, :manager_review)
# Fully chainable:
ExpenseReport.where(user: current_user).pending.order(created_at: :desc)
Scopes filter on the indexed state column, so they stay fast at scale.
Authorization
allow_transition guards are keyed by the state the record is in when the
action happens โ i.e. "who is allowed to act on a record currently in this state".
signoff do
# ...
allow_transition :manager_review do |user|
user.manager?
end
# Guards may also receive the record:
allow_transition :finance_review do |user, record|
user.finance_team? && record.amount <= user.approval_limit
end
end
- A blocked transition raises
Signoff::UnauthorizedErrorwith a descriptive message and leaves the record unchanged (the state is only written inside the transaction, after the guard passes). - If a guard is declared but no user is supplied (and none is set on
Signoff::Current), anUnauthorizedErroris raised too. - States without a guard are open to anyone (e.g.
draftโsubmit!). can_approve?/can_reject?evaluate the same guards but never raise.
Audit Trail
Every transition writes one immutable Signoff::Event row:
| Column | Notes |
|---|---|
workflowable_type / workflowable_id |
Polymorphic owner (the model). |
user_id |
The acting user (nullable). |
action |
"submit", "approve", "reject". |
from_state / to_state |
Strings. |
comment |
Free text. |
metadata |
jsonb, GIN-indexed. |
ip_address / user_agent |
Captured when enabled (see config). |
created_at |
Set automatically; rows are append-only. |
event = report.approve!(user: current_user, comment: "Approved", metadata: { source: "web" })
event.action # => "approve"
event.from_state # => "manager_review"
event.to_state # => "finance_review"
event.user # => #<User ...>
event. # => { "source" => "web" }
Events are read-only once persisted (configurable via
config.immutable_events), guaranteeing a tamper-resistant trail. Useful scopes
are available too: Signoff::Event.chronological, .recent,
.with_action("approve"), .approvals, .rejections.
Capturing request context
Signoff::Current is an ActiveSupport::CurrentAttributes store for the
acting user, ip_address and user_agent. Populate it automatically from your
controllers:
class ApplicationController < ActionController::Base
include Signoff::Controller # sets Current.user / ip / user_agent
end
Then report.approve! (with no user:) is attributed to current_user, and โ when
track_ip_addresses / store_user_agent are enabled โ each event records the
request IP and user agent.
Notifications
Use after_transition to react once the transition has safely committed:
signoff do
# ...
after_transition do |record, event|
WorkflowNotificationJob.perform_later(record.id, event.id)
end
end
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 "approve" then ApprovalMailer.advanced(record, event).deliver_later
when "reject" then ApprovalMailer.rejected(record, event).deliver_later
end
end
end
Because after_transition runs after the transaction commits, the event row is
guaranteed to exist by the time your job runs.
Configuration
Generated at config/initializers/signoff.rb:
Signoff.configure do |config|
config.user_class = "User" # model referenced by Event#user
config.track_ip_addresses = false # persist request IP on events
config.store_user_agent = false # persist request user agent on events
config.default_state_column = :approval_state # default state column for all models
config.event_table_name = "signoff_events"
config.validate_on_transition = false # run full record validation on transition
config.dependent = :delete_all # events strategy when owner is destroyed
config.immutable_events = true # make persisted events read-only
end
Note: because events are immutable by default, use
:delete_allor:nullifyforconfig.dependentโ:destroyinstantiates each event and is blocked by the read-only guard. Setconfig.immutable_events = falseif you need:destroy.
Performance & Scale
Designed for millions of audit rows:
- State lives in an indexed column on the model, so
approved/pending/in_stateare simple, index-backedWHEREqueries โ not correlated subqueries over the events table. - The install migration indexes
workflowable_type,workflowable_id,created_at, a composite(workflowable_type, workflowable_id, created_at)for history reads, a composite(workflowable_type, workflowable_id, action, created_at)forlast_approval/last_rejection/approved_by,user_id, and an optional GIN index onmetadata(skip it with--skip-metadata-indexif you never query metadata, to avoid write amplification). - Concurrency-safe: each transition takes a row lock (
SELECT โฆ FOR UPDATE) and re-checks the current state before writing, so two racingapprove!calls can't both succeed โ the loser gets anInvalidTransitionError. workflow_historyis thesignoff_eventsassociation, so preload it to avoid N+1 queries:
ExpenseReport.includes(:signoff_events).find_each do |report|
report.workflow_history.each { |event| ... } # no extra queries
end
- Transitions write the state column with a single
UPDATEand insert one event row inside one transaction.
Generators
# Migration for the events table + the initializer:
rails generate signoff:install
# create config/initializers/signoff.rb
# create db/migrate/XXXX_create_signoff_events.rb
# Add the state column to a model's table:
rails generate signoff:model ExpenseReport
# create db/migrate/XXXX_add_approval_state_to_expense_reports.rb
# Custom column / initial value:
rails generate signoff:model Invoice --column=workflow_state --initial=pending
Both generators emit migrations stamped with your app's current
ActiveRecord::Migration version, so they work on Rails 7.x and 8.x alike.
Example App
A complete, runnable Rails 8 + PostgreSQL example lives in
examples/expense_approval/. It wires up User and
ExpenseReport, a notification job, the migrations, and a demo script:
cd examples/expense_approval
bundle install
bin/rails db:create db:migrate db:seed
bin/rails runner script/demo.rb # walks a report from draft to approved (and a rejection)
Testing
The suite runs against a real PostgreSQL database (to exercise JSONB) and reports coverage with SimpleCov (enforced โฅ 90%).
bundle install
# Point the suite at your PostgreSQL instance:
export SIGNOFF_PGHOST=localhost SIGNOFF_PGPORT=5432 SIGNOFF_PGUSER=postgres SIGNOFF_PGDATABASE=signoff_test
createdb signoff_test
bundle exec rspec
bundle exec rubocop
The harness reads SIGNOFF_PGHOST, SIGNOFF_PGPORT, SIGNOFF_PGUSER, SIGNOFF_PGPASSWORD,
SIGNOFF_PGDATABASE (falling back to the standard PG* variables). To test a specific
Rails line, export RAILS_VERSION (7.1, 7.2, 8.0, 8.1) before bundle install.
Troubleshooting
Signoff::MissingColumnError โ the model's table has no state column.
Run rails g signoff:model YourModel (or add an indexed string column
named approval_state) and migrate.
Signoff::NotConfiguredError โ you include Signoff but never
declared a signoff do ... end block.
Signoff::InvalidTransitionError: ambiguous transition โ the state has
multiple forward transitions; pass to: to approve! / submit!.
ActiveRecord::ReadOnlyRecord when destroying a record โ events are immutable, so
config.dependent = :destroy is incompatible. Use :delete_all or :nullify, or set
config.immutable_events = false.
Event#user is nil / wrong class โ set config.user_class in the initializer
(it is read when the Event model loads, after initializers run).
Development
bin/setup # install dependencies
bundle exec rspec # run the test suite (needs PostgreSQL)
bundle exec rubocop # lint
bin/console # interactive prompt
Contributing
Bug reports and pull requests are welcome at https://github.com/JijoBose/Signoff. This project follows the Contributor Covenant code of conduct.
License
Available as open source under the terms of the MIT License.