Signoff

CI License: MIT

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_transition guards keyed to the state being acted on.
  • ๐Ÿ“ฃ Notifications โ€” after_transition callbacks 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

  • Ruby >= 3.2
  • Rails 7.1 โ€“ 8.x (activerecord, activesupport, railties)
  • PostgreSQL (the audit trail uses a jsonb column 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.message
  end
  rescue_from Signoff::InvalidTransitionError do |error|
    redirect_back fallback_location: expense_reports_path, alert: error.message
  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 %> &rarr; <%= 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)..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 
    @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!
     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!
     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! and approve! both advance the single forward transition from the current state; they differ only in the audit action recorded ("submit" vs "approve"). Use submit! for the first step out of draft by convention.
  • When a state has more than one forward transition, pass to: to disambiguate (report.approve!(to: :finance_review)).
  • reject! moves the record to the reject_to state.
  • All accept ip_address: and user_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::UnauthorizedError with 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), an UnauthorizedError is 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_all or :nullify for config.dependent โ€” :destroy instantiates each event and is blocked by the read-only guard. Set config.immutable_events = false if you need :destroy.

Performance & Scale

Designed for millions of audit rows:

  • State lives in an indexed column on the model, so approved / pending / in_state are simple, index-backed WHERE queries โ€” 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) for last_approval / last_rejection / approved_by, user_id, and an optional GIN index on metadata (skip it with --skip-metadata-index if 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 racing approve! calls can't both succeed โ€” the loser gets an InvalidTransitionError.
  • workflow_history is the signoff_events association, 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 UPDATE and 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.