Chapter 11 People Operations — Hiring, Leave, Time Tracking
Work in Progress — This chapter is not yet published.
Chapter 11 — People Operations: Hiring, Leave, Time Tracking
People operations is where FOSM earns its keep.
HR software is notoriously bad. Expensive subscriptions, rigid workflows, enterprise lock-in, and still somehow missing the features you actually need. The problem isn’t complexity — hiring, leave management, and time tracking are not complicated domains. The problem is that generic HR tools can’t see your data. They don’t know your org chart, your project structure, your leave policy. They run in a silo.
FOSM changes this. When your HR models live in the same database as your projects, your vendors, your contracts, and your customers, you get a business system with memory. A manager’s leave approval can check their current project commitments. A hire can automatically trigger onboarding tasks. A time entry can validate against an active project. This is the compound value of building your own platform.
This chapter builds three models: Candidate (the hiring lifecycle), LeaveRequest (leave approval), and TimeEntry (time tracking). Together they cover most of what an HR system does. More importantly, they introduce the most important pattern in the business platform: actor-gated transitions — transitions that only certain people are allowed to fire.
The Three Lifecycles at a Glance
Before we build anything, let’s map all three lifecycles. This gives you the mental model for what we’re constructing.
Candidate Lifecycle
stateDiagram-v2
[*] --> applied
applied --> screening : screen
applied --> rejected : reject
screening --> interviewing : interview
screening --> rejected : reject
interviewing --> offer : make_offer
interviewing --> rejected : reject
offer --> hired : hire
offer --> rejected : reject
hired --> [*]
rejected --> [*]
Six states. Five events. Two terminal states. The key insight is that rejection is available at every stage — candidates don’t just fall off the end of the funnel, they get an explicit status.
LeaveRequest Lifecycle
stateDiagram-v2
[*] --> pending
pending --> approved : approve
pending --> rejected : reject
pending --> cancelled : cancel
approved --> [*]
rejected --> [*]
cancelled --> [*]
Four states. Three events. Three terminal states. Simple, but the actor gate is what makes it non-trivial: a manager cannot approve their own leave request.
TimeEntry Lifecycle
stateDiagram-v2
[*] --> logged
logged --> submitted : submit
logged --> rejected : reject
submitted --> approved : approve
submitted --> rejected : reject
approved --> [*]
rejected --> [*]
Four states. Three events. Two terminal states. The same approval pattern as LeaveRequest, but actors are project leads, not managers.
Now let’s build them. We’ll do the full eight steps for Candidate (the most complex), then show the abbreviated pattern for LeaveRequest and TimeEntry — you’ll recognise the structure immediately.
The Candidate Module
Step 1: The Migration
The candidates table needs to store application data, interview metadata, and offer details — everything that feeds the guards and side effects.
Listing 11.1 — db/migrate/20260301100000_create_candidates.rb
class CreateCandidates < ActiveRecord::Migration[8.1]
def change
create_table :candidates do |t|
t.references :job_posting, null: false, foreign_key: true
t.references :created_by_user, null: false, foreign_key: { to_table: :users }
t.references :assigned_hr_user, foreign_key: { to_table: :users }
t.string :full_name, null: false
t.string :email, null: false
t.string :phone
t.string :status, default: "applied", null: false
# Stage data
t.text :resume_text
t.boolean :has_resume_attachment, default: false, null: false
t.text :screening_notes
t.datetime :screened_at
t.references :screened_by_user, foreign_key: { to_table: :users }
t.text :interview_notes
t.datetime :interviewed_at
t.references :interviewed_by_user, foreign_key: { to_table: :users }
# Offer details
t.decimal :offered_salary, precision: 12, scale: 2
t.string :offered_currency, default: "USD"
t.date :offer_expiry_date
t.datetime :offer_made_at
t.references :offer_made_by_user, foreign_key: { to_table: :users }
# Hire / rejection
t.date :proposed_start_date
t.datetime :hired_at
t.datetime :rejected_at
t.text :rejection_reason
t.timestamps
end
add_index :candidates, :status
add_index :candidates, :email
add_index :candidates, [:job_posting_id, :status]
end
end
$ rails db:migrate
A few design choices worth calling out:
Resume is captured two ways. resume_text is a plain-text paste (handy for AI parsing). has_resume_attachment is a boolean flag that your controller sets to true after an Active Storage attachment is uploaded. The guard checks has_resume? — implemented as a model method that combines both signals.
Each stage records who did it and when. screened_at / screened_by_user, interviewed_at / interviewed_by_user, offer_made_at / offer_made_by_user. This gives you a complete audit trail of your hiring process without touching the fosm_transitions table — though that table captures the same data independently.
The compound index on [job_posting_id, status] makes the pipeline view fast. “Show me all candidates for this job posting, grouped by stage” is the most common query in a hiring dashboard.
Step 2: The Model
Listing 11.2 — app/models/candidate.rb
# frozen_string_literal: true
class Candidate < ApplicationRecord
include Fosm::Lifecycle
belongs_to :job_posting
belongs_to :created_by_user, class_name: "User"
belongs_to :assigned_hr_user, class_name: "User", optional: true
belongs_to :screened_by_user, class_name: "User", optional: true
belongs_to :interviewed_by_user, class_name: "User", optional: true
belongs_to :offer_made_by_user, class_name: "User", optional: true
has_one_attached :resume_file
validates :full_name, presence: true
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
enum :status, {
applied: "applied",
screening: "screening",
interviewing: "interviewing",
offer: "offer",
hired: "hired",
rejected: "rejected"
}, default: :applied
# ── FOSM Lifecycle ──────────────────────────────────────────────────────────
# Based on Parolkar's FOSM paper: https://www.parolkar.com/fosm
lifecycle do
state :applied, label: "Applied", color: "slate", initial: true
state :screening, label: "Screening", color: "blue"
state :interviewing, label: "Interviewing", color: "violet"
state :offer, label: "Offer Sent", color: "amber"
state :hired, label: "Hired", color: "green", terminal: true
state :rejected, label: "Rejected", color: "red", terminal: true
event :screen, from: :applied, to: :screening, label: "Move to Screening"
event :interview, from: :screening, to: :interviewing, label: "Schedule Interview"
event :make_offer, from: :interviewing, to: :offer, label: "Make Offer"
event :hire, from: :offer, to: :hired, label: "Confirm Hire"
event :reject, from: [:applied, :screening, :interviewing, :offer], to: :rejected, label: "Reject"
actors :human
# Guards
guard :has_resume, on: :screen,
description: "Candidate must have a resume on file" do |candidate|
candidate.has_resume?
end
guard :has_interview_notes, on: :make_offer,
description: "Interview notes must be recorded before making an offer" do |candidate|
candidate.interview_notes.present?
end
guard :has_offer_details, on: :hire,
description: "Offer salary and start date must be set" do |candidate|
candidate.offered_salary.present? && candidate.proposed_start_date.present?
end
# Side effects
side_effect :send_offer_email, on: :make_offer,
description: "Email offer notification to candidate" do |candidate, _transition|
CandidateMailer.offer_notification(candidate).deliver_later
end
side_effect :notify_hr_on_hire, on: :hire,
description: "Notify HR team of confirmed hire" do |candidate, _transition|
candidate.assigned_hr_user&.notify!(
title: "New hire confirmed",
body: "#{candidate.full_name} has accepted the offer for #{candidate.job_posting.title}",
record: candidate
)
HrSlackNotifier.hire_confirmed(candidate)
end
side_effect :record_hire_timestamp, on: :hire,
description: "Stamp hired_at on confirmation" do |candidate, _transition|
candidate.update_column(:hired_at, Time.current)
end
side_effect :record_rejection_timestamp, on: :reject,
description: "Stamp rejected_at" do |candidate, _transition|
candidate.update_column(:rejected_at, Time.current)
end
end
# ── End Lifecycle ────────────────────────────────────────────────────────────
scope :active_pipeline, -> { where.not(status: [:hired, :rejected]) }
scope :for_job, ->(job) { where(job_posting: job) }
scope :hired_this_month, -> {
where(status: :hired).where(hired_at: Time.current.beginning_of_month..)
}
# ── Public API ──────────────────────────────────────────────────────────────
def has_resume?
resume_text.present? || has_resume_attachment?
end
def pipeline_stage_number
%w[applied screening interviewing offer hired rejected].index(status.to_s) + 1
end
def days_in_pipeline
return nil unless applied? || screening? || interviewing? || offer?
(Date.current - created_at.to_date).to_i
end
def screen!(actor:, notes: nil)
self.screening_notes = notes if notes
self.screened_at = Time.current
self.screened_by_user = actor
save!
transition!(:screen, actor: actor)
end
def schedule_interview!(actor:, notes: nil)
self.interviewed_at = Time.current
self.interviewed_by_user = actor
self.interview_notes = notes if notes
save!
transition!(:interview, actor: actor)
end
def make_offer!(actor:, salary:, currency: "USD", expiry_days: 7, start_date: nil)
self.offered_salary = salary
self.offered_currency = currency
self.offer_expiry_date = expiry_days.days.from_now.to_date
self.offer_made_at = Time.current
self.offer_made_by_user = actor
self.proposed_start_date = start_date
save!
transition!(:make_offer, actor: actor)
end
def confirm_hire!(actor:)
transition!(:hire, actor: actor)
end
def reject!(actor:, reason: nil)
self.rejection_reason = reason
save!
transition!(:reject, actor: actor)
end
end
The model’s public API methods (screen!, make_offer!, confirm_hire!) are thin wrappers around transition!. They collect the stage-specific data, save it, then fire the transition. This keeps controllers dead simple — they call the model method and let the model handle the rest.
candidate.make_offer!(actor: current_user, salary: 95_000), the sequence is: (1) set salary and offer fields, (2) call save!, (3) call transition!(:make_offer). The guard has_interview_notes fires inside transition! — which means the data is already saved before the guard runs. If you're worried about inconsistent state, don't be: the guard checks interview_notes.present? on the persisted record, and if it fails, the status doesn't change. The salary update happened, but the transition didn't. In practice, your controller validates interview notes exist before calling make_offer! — the guard is the final backstop, not the primary check.
Step 3: The Controller
Listing 11.3 — app/controllers/candidates_controller.rb
# frozen_string_literal: true
class CandidatesController < ApplicationController
before_action :authenticate_user!
before_action :set_candidate, only: [:show, :edit, :update, :screen,
:schedule_interview, :make_offer,
:confirm_hire, :reject]
def index
@job_posting = JobPosting.find(params[:job_posting_id]) if params[:job_posting_id]
@candidates = candidate_scope.order(created_at: :desc)
@candidates = @candidates.where(status: params[:status]) if params[:status].present?
@candidates = @candidates.for_job(@job_posting) if @job_posting
@pipeline_counts = candidate_scope.group(:status).count
end
def show
@transitions = @candidate.fosm_transitions.order(created_at: :desc)
end
def new
@candidate = Candidate.new
@job_postings = JobPosting.open
end
def create
@candidate = Candidate.new(candidate_params)
@candidate.created_by_user = current_user
@candidate.assigned_hr_user = current_user if current_user.hr?
if @candidate.save
redirect_to @candidate, notice: "Candidate #{@candidate.full_name} added to pipeline."
else
@job_postings = JobPosting.open
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @candidate.update(candidate_params)
redirect_to @candidate, notice: "Candidate updated."
else
render :edit, status: :unprocessable_entity
end
end
# ── Transition actions ───────────────────────────────────────────────────────
def screen
@candidate.screen!(
actor: current_user,
notes: params.dig(:candidate, :screening_notes)
)
redirect_to @candidate, notice: "#{@candidate.full_name} moved to screening."
rescue Fosm::TransitionError => e
redirect_to @candidate, alert: "Cannot move to screening: #{e.message}"
end
def schedule_interview
@candidate.schedule_interview!(
actor: current_user,
notes: params.dig(:candidate, :interview_notes)
)
redirect_to @candidate, notice: "Interview stage recorded."
rescue Fosm::TransitionError => e
redirect_to @candidate, alert: "Cannot schedule interview: #{e.message}"
end
def make_offer
@candidate.make_offer!(
actor: current_user,
salary: params.dig(:candidate, :offered_salary),
currency: params.dig(:candidate, :offered_currency) || "USD",
start_date: params.dig(:candidate, :proposed_start_date)
)
redirect_to @candidate, notice: "Offer sent to #{@candidate.full_name}."
rescue Fosm::TransitionError => e
redirect_to @candidate, alert: "Cannot make offer: #{e.message}"
end
def confirm_hire
@candidate.confirm_hire!(actor: current_user)
redirect_to @candidate, notice: "#{@candidate.full_name} confirmed as hired. HR has been notified."
rescue Fosm::TransitionError => e
redirect_to @candidate, alert: "Cannot confirm hire: #{e.message}"
end
def reject
@candidate.reject!(
actor: current_user,
reason: params.dig(:candidate, :rejection_reason)
)
redirect_to candidates_path, notice: "#{@candidate.full_name} has been rejected."
rescue Fosm::TransitionError => e
redirect_to @candidate, alert: "Cannot reject: #{e.message}"
end
private
def set_candidate
@candidate = Candidate.find(params[:id])
end
def candidate_scope
Candidate.includes(:job_posting, :assigned_hr_user)
end
def candidate_params
params.require(:candidate).permit(
:job_posting_id, :full_name, :email, :phone,
:resume_text, :resume_file,
:offered_salary, :offered_currency, :proposed_start_date,
:offer_expiry_date, :rejection_reason
)
end
end
Notice that every transition action follows the same three-line pattern: call the model method, redirect on success, rescue Fosm::TransitionError on failure. The controller contains zero business logic — it’s just HTTP glue.
Step 4: Routes
Listing 11.4 — config/routes.rb (candidates excerpt)
resources :candidates do
member do
post :screen
post :schedule_interview
post :make_offer
post :confirm_hire
post :reject
end
end
# Nested under job postings for pipeline view
resources :job_postings do
resources :candidates, only: [:index, :new, :create]
end
Transition routes are member routes — they act on a specific candidate identified by :id. They’re all POST because they change state.
$ rails routes | grep candidates
You’ll see clean, RESTful routes like POST /candidates/:id/screen, POST /candidates/:id/make_offer. These are straightforward to document, audit, and protect with authorization.
Step 5: Views (Pipeline Board)
The candidate pipeline view is more valuable as a Kanban board than a table. Each column is a stage; cards show the candidate name, the job posting, and how many days they’ve been in the pipeline.
Listing 11.5 — app/views/candidates/index.html.erb
<div class="page-header">
<h1>Hiring Pipeline</h1>
<% if @job_posting %>
<span class="badge badge-neutral"><%= @job_posting.title %></span>
<% end %>
<%= link_to "Add Candidate", new_candidate_path, class: "btn btn-primary" %>
</div>
<div class="pipeline-board">
<% %w[applied screening interviewing offer hired].each do |stage| %>
<div class="pipeline-column" data-stage="<%= stage %>">
<div class="pipeline-column-header">
<span class="stage-label"><%= stage.titleize %></span>
<span class="stage-count badge"><%= @pipeline_counts[stage] || 0 %></span>
</div>
<div class="pipeline-cards">
<% @candidates.select { |c| c.status == stage }.each do |candidate| %>
<div class="pipeline-card">
<div class="candidate-name">
<%= link_to candidate.full_name, candidate_path(candidate) %>
</div>
<div class="candidate-meta">
<span class="job-title"><%= candidate.job_posting.title %></span>
<% if candidate.days_in_pipeline %>
<span class="days-badge <% if candidate.days_in_pipeline > 30 %>days-warning<% end %>">
<%= candidate.days_in_pipeline %>d
</span>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
Listing 11.6 — app/views/candidates/show.html.erb (transition buttons)
<div class="candidate-header">
<div class="candidate-info">
<h1><%= @candidate.full_name %></h1>
<p class="candidate-sub"><%= @candidate.job_posting.title %></p>
</div>
<%= render "fosm/status_badge", record: @candidate %>
</div>
<div class="candidate-actions">
<% case @candidate.status %>
<% when "applied" %>
<% if @candidate.has_resume? %>
<%= button_to "Move to Screening", screen_candidate_path(@candidate),
method: :post, class: "btn btn-primary" %>
<% else %>
<div class="action-blocked">
Upload a resume before moving to screening.
</div>
<% end %>
<% when "screening" %>
<%= form_with url: schedule_interview_candidate_path(@candidate), method: :post do |f| %>
<%= f.text_area :interview_notes, class: "form-control",
placeholder: "Interview notes (required to proceed to offer stage)" %>
<%= f.submit "Move to Interviewing", class: "btn btn-primary" %>
<% end %>
<% when "interviewing" %>
<%= form_with url: make_offer_candidate_path(@candidate), method: :post do |f| %>
<div class="form-row">
<%= f.number_field :offered_salary, class: "form-control", placeholder: "Salary" %>
<%= f.text_field :offered_currency, value: "USD", class: "form-control" %>
<%= f.date_field :proposed_start_date, class: "form-control" %>
</div>
<%= f.submit "Make Offer", class: "btn btn-primary" %>
<% end %>
<% when "offer" %>
<%= button_to "Confirm Hire", confirm_hire_candidate_path(@candidate),
method: :post, class: "btn btn-success",
data: { confirm: "Confirm hire for #{@candidate.full_name}?" } %>
<% end %>
<% unless @candidate.terminal? %>
<%= form_with url: reject_candidate_path(@candidate), method: :post do |f| %>
<%= f.text_area :rejection_reason, placeholder: "Rejection reason (optional)" %>
<%= f.submit "Reject Candidate", class: "btn btn-danger" %>
<% end %>
<% end %>
</div>
<div class="transition-log">
<h3>History</h3>
<%= render "fosm/transition_log", transitions: @transitions %>
</div>
Step 6: Bot Tool Integration
Listing 11.7 — app/services/candidate_query_service.rb
# frozen_string_literal: true
class CandidateQueryService
# Query candidates with optional filters
def self.query(status: nil, job_posting_id: nil, days_in_pipeline_gt: nil, limit: 20)
scope = Candidate.includes(:job_posting, :assigned_hr_user).order(created_at: :desc)
scope = scope.where(status: status) if status.present?
scope = scope.where(job_posting_id: job_posting_id) if job_posting_id.present?
if days_in_pipeline_gt.present?
cutoff = days_in_pipeline_gt.to_i.days.ago
scope = scope.where("candidates.created_at <= ?", cutoff)
.where.not(status: [:hired, :rejected])
end
scope.limit(limit).map do |c|
{
id: c.id,
full_name: c.full_name,
email: c.email,
status: c.status,
job_title: c.job_posting.title,
days_in_pipeline: c.days_in_pipeline,
has_resume: c.has_resume?,
assigned_hr: c.assigned_hr_user&.full_name,
hired_at: c.hired_at&.iso8601,
rejection_reason: c.rejection_reason
}
end
end
# Pipeline summary: count by stage
def self.pipeline_summary(job_posting_id: nil)
scope = Candidate.all
scope = scope.where(job_posting_id: job_posting_id) if job_posting_id.present?
scope.group(:status).count
end
# Time-to-hire metric for completed hires
def self.time_to_hire_stats
hires = Candidate.hired.where.not(hired_at: nil)
return { count: 0 } if hires.none?
days_list = hires.map { |c| (c.hired_at.to_date - c.created_at.to_date).to_i }
{
count: days_list.size,
average_days: (days_list.sum.to_f / days_list.size).round(1),
min_days: days_list.min,
max_days: days_list.max
}
end
end
Listing 11.8 — app/tools/candidate_query_tool.rb
# frozen_string_literal: true
class CandidateQueryTool < ApplicationTool
tool_name "query_candidates"
description "Query the hiring pipeline. Filter by status, job posting, or time in pipeline. " \
"Also returns pipeline summary counts and time-to-hire statistics."
argument :action, type: :string, required: true,
enum: ["list", "pipeline_summary", "time_to_hire"],
description: "What to retrieve"
argument :status, type: :string, required: false,
enum: %w[applied screening interviewing offer hired rejected],
description: "Filter by candidate status"
argument :job_posting_id, type: :integer, required: false,
description: "Filter by job posting ID"
argument :days_in_pipeline_gt, type: :integer, required: false,
description: "Return only candidates who have been in the pipeline longer than N days (active stages only)"
argument :limit, type: :integer, required: false, default: 20,
description: "Maximum number of candidates to return (max 50)"
def call
case arguments[:action]
when "list"
CandidateQueryService.query(
status: arguments[:status],
job_posting_id: arguments[:job_posting_id],
days_in_pipeline_gt: arguments[:days_in_pipeline_gt],
limit: [arguments[:limit] || 20, 50].min
)
when "pipeline_summary"
CandidateQueryService.pipeline_summary(job_posting_id: arguments[:job_posting_id])
when "time_to_hire"
CandidateQueryService.time_to_hire_stats
else
{ error: "Unknown action: #{arguments[:action]}" }
end
end
end
Step 7: Module Setting
Register the module in your FOSM settings so it appears in the navigation, the bot’s tool registry, and the home page tile generator.
Listing 11.9 — config/fosm_modules.rb (candidates excerpt)
Fosm.configure do |config|
config.register_module :candidates do |mod|
mod.label = "Hiring Pipeline"
mod.icon = "user-plus"
mod.description = "Track candidates from application through hire"
mod.model = Candidate
mod.query_tool = CandidateQueryTool
mod.nav_group = :people
mod.color = "violet"
end
end
Step 8: Home Page Tile
Listing 11.10 — app/views/home/_candidates_tile.html.erb
<div class="home-tile" data-module="candidates">
<div class="tile-header">
<span class="tile-icon"><%# heroicon "user-plus" %></span>
<h3 class="tile-title">Hiring Pipeline</h3>
<%= link_to "View all", candidates_path, class: "tile-link" %>
</div>
<div class="pipeline-mini">
<% @candidate_pipeline_counts.each do |stage, count| %>
<% next if %w[hired rejected].include?(stage) %>
<div class="mini-stage">
<span class="mini-stage-label"><%= stage.titleize %></span>
<span class="mini-stage-count"><%= count %></span>
</div>
<% end %>
</div>
<div class="tile-stat">
<span class="stat-label">Hired this month</span>
<span class="stat-value"><%= @candidates_hired_this_month %></span>
</div>
<% if @candidates_needing_action.any? %>
<div class="tile-alerts">
<% @candidates_needing_action.first(3).each do |candidate| %>
<div class="tile-alert-item">
<%= link_to candidate.full_name, candidate_path(candidate) %>
<span class="alert-badge">
<%= candidate.days_in_pipeline %>d in <%= candidate.status.titleize %>
</span>
</div>
<% end %>
</div>
<% end %>
</div>
The LeaveRequest Module
LeaveRequest introduces the most important actor gate in the platform: you cannot approve your own leave request. This seems obvious, but it’s not enforced automatically by most HR systems — they rely on org hierarchy configuration that’s always slightly out of sync with reality. In FOSM, the rule is in the model and is always current.
Migration and Model
Listing 11.11 — db/migrate/20260301110000_create_leave_requests.rb
class CreateLeaveRequests < ActiveRecord::Migration[8.1]
def change
create_table :leave_requests do |t|
t.references :user, null: false, foreign_key: true
t.references :approved_by, foreign_key: { to_table: :users }
t.references :rejected_by, foreign_key: { to_table: :users }
t.string :leave_type, null: false # annual, sick, personal, unpaid
t.date :start_date, null: false
t.date :end_date, null: false
t.integer :days_requested, null: false
t.string :status, default: "pending", null: false
t.text :reason
t.text :rejection_reason
t.datetime :approved_at
t.datetime :rejected_at
t.datetime :cancelled_at
t.timestamps
end
add_index :leave_requests, :status
add_index :leave_requests, [:user_id, :status]
add_index :leave_requests, [:user_id, :start_date]
end
end
Listing 11.12 — app/models/leave_request.rb
# frozen_string_literal: true
class LeaveRequest < ApplicationRecord
include Fosm::Lifecycle
belongs_to :user
belongs_to :approved_by, class_name: "User", optional: true
belongs_to :rejected_by, class_name: "User", optional: true
validates :leave_type, presence: true,
inclusion: { in: %w[annual sick personal unpaid] }
validates :start_date, :end_date, presence: true
validates :days_requested, presence: true,
numericality: { greater_than: 0, less_than_or_equal_to: 30 }
validate :end_date_after_start_date
enum :status, {
pending: "pending",
approved: "approved",
rejected: "rejected",
cancelled: "cancelled"
}, default: :pending
# ── FOSM Lifecycle ──────────────────────────────────────────────────────────
# Based on Parolkar's FOSM paper: https://www.parolkar.com/fosm
lifecycle do
state :pending, label: "Pending", color: "amber", initial: true
state :approved, label: "Approved", color: "green", terminal: true
state :rejected, label: "Rejected", color: "red", terminal: true
state :cancelled, label: "Cancelled", color: "slate", terminal: true
event :approve, from: :pending, to: :approved, label: "Approve"
event :reject, from: :pending, to: :rejected, label: "Reject"
event :cancel, from: :pending, to: :cancelled, label: "Cancel"
actors :human
# Actor gate: can't approve your own leave request
guard :not_self_approval, on: :approve,
description: "Managers cannot approve their own leave requests" do |request, transition|
transition.actor_id != request.user_id
end
# Data guard: must have available leave balance
guard :has_leave_balance, on: :approve,
description: "Employee must have sufficient leave balance" do |request|
LeaveBalance.for(request.user, request.leave_type) >= request.days_requested
end
# Side effect: deduct from leave balance when approved
side_effect :deduct_leave_balance, on: :approve,
description: "Deduct approved days from leave balance" do |request, _transition|
LeaveBalance.deduct!(request.user, request.leave_type, request.days_requested)
end
side_effect :stamp_approved_at, on: :approve do |request, transition|
request.update_columns(
approved_at: Time.current,
approved_by_id: transition.actor_id
)
end
side_effect :stamp_rejected_at, on: :reject do |request, transition|
request.update_columns(
rejected_at: Time.current,
rejected_by_id: transition.actor_id
)
end
side_effect :notify_employee, on: [:approve, :reject],
description: "Notify the employee of the decision" do |request, transition|
decision = transition.to_state == "approved" ? "approved" : "rejected"
request.user.notify!(
title: "Leave request #{decision}",
body: "Your #{request.leave_type} leave request for #{request.start_date} " \
"to #{request.end_date} has been #{decision}.",
record: request
)
end
end
# ── End Lifecycle ────────────────────────────────────────────────────────────
scope :pending_approval, -> { where(status: :pending) }
scope :for_user, ->(u) { where(user: u) }
scope :overlapping, ->(start_d, end_d) {
where("start_date <= ? AND end_date >= ?", end_d, start_d)
}
def approve!(actor:)
transition!(:approve, actor: actor)
end
def reject!(actor:, reason: nil)
self.rejection_reason = reason
save!
transition!(:reject, actor: actor)
end
def cancel!(actor:)
transition!(:cancel, actor: actor)
end
private
def end_date_after_start_date
return unless start_date && end_date
errors.add(:end_date, "must be on or after start date") if end_date < start_date
end
end
The actor gate pattern is worth examining closely. The guard receives both the record and the transition object. The transition carries the actor’s identity. So transition.actor_id != request.user_id is a single-line enforcement of a multi-step organizational rule. No configuration. No role hierarchy lookup. Just data.
actor_id, actor_type, and any metadata the caller passes in. This makes actor-gated rules first-class citizens. The guard sees who is attempting the transition and can make decisions accordingly. Without this, actor gates have to live in the controller — which means they can be bypassed, forgotten, or duplicated across multiple controllers. In FOSM, the rule is in the model and is tested alongside the lifecycle.
LeaveRequest Controller (abbreviated)
The controller is the same approval pattern you saw in Candidate, just shorter:
Listing 11.13 — app/controllers/leave_requests_controller.rb
# frozen_string_literal: true
class LeaveRequestsController < ApplicationController
before_action :authenticate_user!
before_action :set_leave_request, only: [:show, :approve, :reject, :cancel]
def index
@leave_requests = LeaveRequest.includes(:user, :approved_by)
.order(created_at: :desc)
# Managers see team requests; employees see their own
@leave_requests = if current_user.manager?
@leave_requests.pending_approval
else
@leave_requests.for_user(current_user)
end
end
def create
@leave_request = LeaveRequest.new(leave_request_params)
@leave_request.user = current_user
if @leave_request.save
redirect_to @leave_request, notice: "Leave request submitted."
else
render :new, status: :unprocessable_entity
end
end
def approve
@leave_request.approve!(actor: current_user)
redirect_to leave_requests_path, notice: "Leave approved."
rescue Fosm::TransitionError => e
redirect_to @leave_request, alert: "Cannot approve: #{e.message}"
end
def reject
@leave_request.reject!(
actor: current_user,
reason: params.dig(:leave_request, :rejection_reason)
)
redirect_to leave_requests_path, notice: "Leave rejected."
rescue Fosm::TransitionError => e
redirect_to @leave_request, alert: "Cannot reject: #{e.message}"
end
def cancel
@leave_request.cancel!(actor: current_user)
redirect_to leave_requests_path, notice: "Leave request cancelled."
rescue Fosm::TransitionError => e
redirect_to @leave_request, alert: "Cannot cancel: #{e.message}"
end
private
def set_leave_request
@leave_request = LeaveRequest.find(params[:id])
end
def leave_request_params
params.require(:leave_request).permit(
:leave_type, :start_date, :end_date, :days_requested, :reason
)
end
end
When a manager tries to approve their own leave request, transition! fires the not_self_approval guard, which fails, raising Fosm::TransitionError. The controller rescues this and shows an alert. The rule is enforced without any if current_user == @leave_request.user check in the controller.
LeaveRequest Routes and QueryTool
# config/routes.rb
resources :leave_requests do
member do
post :approve
post :reject
post :cancel
end
end
Listing 11.14 — app/tools/leave_request_query_tool.rb
# frozen_string_literal: true
class LeaveRequestQueryTool < ApplicationTool
tool_name "query_leave_requests"
description "Query leave requests. Filter by status, user, leave type, or date range. " \
"Returns pending approvals, approved leaves, and leave balance summaries."
argument :action, type: :string, required: true,
enum: ["list", "pending_approvals", "balance_summary"],
description: "What to retrieve"
argument :status, type: :string, required: false,
enum: %w[pending approved rejected cancelled]
argument :user_id, type: :integer, required: false,
description: "Filter by employee user ID"
argument :leave_type, type: :string, required: false,
enum: %w[annual sick personal unpaid]
argument :start_date_from, type: :string, required: false,
description: "ISO8601 date — return requests with start_date on or after this date"
def call
case arguments[:action]
when "list"
scope = LeaveRequest.includes(:user).order(created_at: :desc).limit(30)
scope = scope.where(status: arguments[:status]) if arguments[:status]
scope = scope.where(user_id: arguments[:user_id]) if arguments[:user_id]
scope = scope.where(leave_type: arguments[:leave_type]) if arguments[:leave_type]
if arguments[:start_date_from]
scope = scope.where("start_date >= ?", Date.parse(arguments[:start_date_from]))
end
scope.map { |r| serialize_request(r) }
when "pending_approvals"
LeaveRequest.pending_approval.includes(:user).order(:start_date).map { |r| serialize_request(r) }
when "balance_summary"
user = User.find(arguments[:user_id]) if arguments[:user_id]
LeaveBalance.summary_for(user || :all)
end
end
private
def serialize_request(r)
{
id: r.id,
employee: r.user.full_name,
leave_type: r.leave_type,
start_date: r.start_date.iso8601,
end_date: r.end_date.iso8601,
days_requested: r.days_requested,
status: r.status,
reason: r.reason
}
end
end
The TimeEntry Module
TimeEntry is the approval-pattern applied to billable work. The actor gate here prevents project leads from approving their own hours — the same principle as LeaveRequest, applied to time tracking.
The key guard is has_valid_project_reference — you can’t submit time against a project that doesn’t exist or isn’t active. This is where FOSM’s advantage over a standalone time-tracking tool becomes viscerally clear: the guard has direct access to your Project model.
Migration and Model
Listing 11.15 — db/migrate/20260301120000_create_time_entries.rb
class CreateTimeEntries < ActiveRecord::Migration[8.1]
def change
create_table :time_entries do |t|
t.references :user, null: false, foreign_key: true
t.references :project, null: false, foreign_key: true
t.references :approved_by, foreign_key: { to_table: :users }
t.date :entry_date, null: false
t.decimal :hours, precision: 5, scale: 2, null: false
t.string :description, null: false
t.string :status, default: "logged", null: false
t.text :rejection_notes
t.datetime :submitted_at
t.datetime :approved_at
t.timestamps
end
add_index :time_entries, :status
add_index :time_entries, [:project_id, :status]
add_index :time_entries, [:user_id, :entry_date]
add_index :time_entries, [:project_id, :entry_date]
end
end
Listing 11.16 — app/models/time_entry.rb
# frozen_string_literal: true
class TimeEntry < ApplicationRecord
include Fosm::Lifecycle
belongs_to :user
belongs_to :project
belongs_to :approved_by, class_name: "User", optional: true
validates :entry_date, presence: true
validates :hours, presence: true,
numericality: { greater_than: 0, less_than_or_equal_to: 24 }
validates :description, presence: true, length: { maximum: 500 }
enum :status, {
logged: "logged",
submitted: "submitted",
approved: "approved",
rejected: "rejected"
}, default: :logged
# ── FOSM Lifecycle ──────────────────────────────────────────────────────────
# Based on Parolkar's FOSM paper: https://www.parolkar.com/fosm
lifecycle do
state :logged, label: "Logged", color: "slate", initial: true
state :submitted, label: "Submitted", color: "blue"
state :approved, label: "Approved", color: "green", terminal: true
state :rejected, label: "Rejected", color: "red", terminal: true
event :submit, from: :logged, to: :submitted, label: "Submit for Approval"
event :approve, from: :submitted, to: :approved, label: "Approve"
event :reject, from: [:logged, :submitted], to: :rejected, label: "Reject"
actors :human
# Actor gate: can't approve own time entry
guard :not_self_approval, on: :approve,
description: "Project leads cannot approve their own time entries" do |entry, transition|
transition.actor_id != entry.user_id
end
# Data guard: project must be active
guard :has_valid_project, on: :submit,
description: "Time can only be submitted against an active project" do |entry|
entry.project.active?
end
# Side effect: update project hours on approval
side_effect :update_project_hours, on: :approve,
description: "Add approved hours to project total" do |entry, _transition|
entry.project.increment!(:approved_hours, entry.hours)
end
side_effect :stamp_submitted_at, on: :submit do |entry, _transition|
entry.update_column(:submitted_at, Time.current)
end
side_effect :stamp_approved_at, on: :approve do |entry, transition|
entry.update_columns(
approved_at: Time.current,
approved_by_id: transition.actor_id
)
end
end
# ── End Lifecycle ────────────────────────────────────────────────────────────
scope :for_project, ->(p) { where(project: p) }
scope :for_user, ->(u) { where(user: u) }
scope :for_period, ->(s, e) { where(entry_date: s..e) }
scope :billable, -> { joins(:project).where(projects: { billable: true }) }
def submit!(actor:)
self.submitted_at = Time.current
save!
transition!(:submit, actor: actor)
end
def approve!(actor:)
transition!(:approve, actor: actor)
end
def reject!(actor:, notes: nil)
self.rejection_notes = notes
save!
transition!(:reject, actor: actor)
end
end
The has_valid_project guard calls entry.project.active? — a method on your Project model, which we’ll build in the next chapter. This cross-model guard is a concrete example of why building on a shared data model beats a collection of SaaS integrations: the guard has access to the full application context.
TimeEntry Controller and QueryTool
The controller follows the exact same pattern as LeaveRequest. Here’s just the transitions part — by now, the full CRUD is routine:
# In TimeEntriesController
def submit
@time_entry.submit!(actor: current_user)
redirect_to @time_entry, notice: "Time entry submitted for approval."
rescue Fosm::TransitionError => e
redirect_to @time_entry, alert: "Cannot submit: #{e.message}"
end
def approve
@time_entry.approve!(actor: current_user)
redirect_to project_time_entries_path(@time_entry.project),
notice: "#{@time_entry.hours} hours approved."
rescue Fosm::TransitionError => e
redirect_to @time_entry, alert: "Cannot approve: #{e.message}"
end
Listing 11.17 — app/tools/time_entry_query_tool.rb
# frozen_string_literal: true
class TimeEntryQueryTool < ApplicationTool
tool_name "query_time_entries"
description "Query time entries. Filter by status, project, user, or date range. " \
"Returns summaries of billable hours, pending approvals, and project time totals."
argument :action, type: :string, required: true,
enum: ["list", "pending_approvals", "project_summary", "user_summary"],
description: "What to retrieve"
argument :project_id, type: :integer, required: false
argument :user_id, type: :integer, required: false
argument :status, type: :string, required: false,
enum: %w[logged submitted approved rejected]
argument :date_from, type: :string, required: false, description: "ISO8601 date"
argument :date_to, type: :string, required: false, description: "ISO8601 date"
def call
case arguments[:action]
when "list"
scope = build_scope
scope.limit(50).map { |e| serialize(e) }
when "pending_approvals"
TimeEntry.submitted.includes(:user, :project).order(:submitted_at).map { |e| serialize(e) }
when "project_summary"
project = Project.find(arguments[:project_id])
{
project: project.name,
total_approved_hours: project.approved_hours,
budget_hours: project.budget_hours,
utilization_pct: project.budget_hours > 0 ?
(project.approved_hours / project.budget_hours * 100).round(1) : nil,
pending_hours: TimeEntry.submitted.for_project(project).sum(:hours)
}
when "user_summary"
user = User.find(arguments[:user_id])
entries = TimeEntry.approved.for_user(user)
entries = entries.for_period(Date.parse(arguments[:date_from]),
Date.parse(arguments[:date_to])) if arguments[:date_from]
{
user: user.full_name,
total_approved_hours: entries.sum(:hours),
by_project: entries.group_by { |e| e.project.name }
.transform_values { |es| es.sum(&:hours) }
}
end
end
private
def build_scope
scope = TimeEntry.includes(:user, :project).order(entry_date: :desc)
scope = scope.where(project_id: arguments[:project_id]) if arguments[:project_id]
scope = scope.where(user_id: arguments[:user_id]) if arguments[:user_id]
scope = scope.where(status: arguments[:status]) if arguments[:status]
if arguments[:date_from]
scope = scope.where("entry_date >= ?", Date.parse(arguments[:date_from]))
end
if arguments[:date_to]
scope = scope.where("entry_date <= ?", Date.parse(arguments[:date_to]))
end
scope
end
def serialize(e)
{
id: e.id,
user: e.user.full_name,
project: e.project.name,
entry_date: e.entry_date.iso8601,
hours: e.hours,
description: e.description,
status: e.status,
submitted_at: e.submitted_at&.iso8601,
approved_at: e.approved_at&.iso8601
}
end
end
The Approval Pattern
Looking at LeaveRequest and TimeEntry side by side, the pattern is clear:
| Element | LeaveRequest | TimeEntry |
|---|---|---|
| States | pending → approved/rejected/cancelled | logged → submitted → approved/rejected |
| Actor gate | can’t approve own leave | can’t approve own time |
| Data guard | must have leave balance | must have active project |
| Side effect | deduct leave balance | increment project hours |
| QueryTool | query_leave_requests |
query_time_entries |
The approval pattern (submit → approve/reject with actor validation) recurs throughout the business platform. Every time something needs a second set of eyes — a purchase order, a contract, an expense claim — this is the pattern you’ll reach for.
Approval model and attach it to anything? We've tried it. Generic approval rails are harder to reason about, harder to extend, and the side effects are always domain-specific anyway. A LeaveRequest approval deducts a balance. A TimeEntry approval increments project hours. A PurchaseOrder approval updates a budget line. These side effects have no business being in a generic model. Keep the pattern, repeat the structure, keep the domain logic where it belongs.
Actor Gates and Chapter 15
The actor gates in this chapter are implemented as guards that inspect transition.actor_id. This works well, but it has a limitation: the logic for “who counts as a manager” is distributed across multiple guards and controllers. In a small team, this is fine. As your org grows and the rules get more complex — “only the direct reporting manager, or their delegate, or an HR admin” — you’ll want a centralized access control primitive.
Chapter 15 builds exactly this. It introduces an ActorPolicy module that centralises actor rules and lets you compose them. The guards you write today will slot directly into that system — the call signature is identical. What changes is where the logic lives.
For now, the pattern you have is correct and complete for most small-to-medium businesses.
What You Built
-
Three complete FOSM modules covering the core people operations domain: hiring pipeline, leave management, and time tracking.
-
The Candidate model with six states, five events, three guards (resume required, interview notes required, offer details required), and two side effects (email on offer, HR notification on hire). Full pipeline board view.
-
The LeaveRequest model with the first actor-gated transition in the platform: managers cannot approve their own leave. The guard receives the transition object to check actor identity. Side effect deducts leave balance atomically on approval.
-
The TimeEntry model with a cross-model guard (
has_valid_project) that validates against the Project model — a concrete demonstration of the compound value of a shared data model. Side effect increments project hours on approval. -
The approval pattern documented as a reusable template: submit → approve/reject with actor gate, data guard, and domain-specific side effect.
-
Three QueryTools (
query_candidates,query_leave_requests,query_time_entries) that give your AI assistant a structured view into the people operations pipeline — hiring metrics, pending approvals, project time summaries. -
A clear path to Chapter 15 — the actor gate pattern you’ve built here is the manual, per-model precursor to the centralised Access Control primitive.