Class: ApprovalEngine::Step

Inherits:
ApplicationRecord show all
Defined in:
app/models/approval_engine/step.rb

Overview

A single node in the immutable approval ledger.

Steps move strictly forward through their lifecycle — they are never reset backwards. Requesting changes appends a fresh iteration instead (see IterationBuilder), preserving the historical truth of every attempt.

The bang methods (#approve!, #reject!, #request_changes!) take a pessimistic lock, write an audit row, advance the surrounding track, and drop a transactional-outbox event — all in one transaction — so concurrent “Approve” clicks can never double-resolve a step.

Constant Summary collapse

STATUSES =

Lifecycle. ‘waiting` steps belong to a future layer and are not yet actionable; they are activated to `pending` once the prior layer resolves.

%w[waiting pending approved rejected changes_requested expired cancelled].freeze
TERMINAL_STATUSES =
%w[approved rejected changes_requested expired cancelled].freeze
TRANSITIONS =

Allowed forward transitions. Anything else is rejected to keep the ledger append-only. ‘expired` is a distinct terminal state — a deadline lapse is never recorded as an approval or a human rejection.

{
  "waiting" => %w[pending cancelled],
  "pending" => %w[approved rejected changes_requested expired cancelled]
}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.sweep_timeouts!(tenant_id: nil) ⇒ Object

Fire the timeout signal for every overdue step. Safe to run as often as you like (each step times out once); scope to a tenant in multi-tenant cron. Returns the number swept. One step raising (lock contention, a concurrently destroyed approval) is logged and skipped, so it can’t starve the rest of the batch — the next sweep retries it (idempotent). TimeoutSweepJob wraps this for background runs.



183
184
185
186
187
188
189
190
191
192
193
# File 'app/models/approval_engine/step.rb', line 183

def self.sweep_timeouts!(tenant_id: nil)
  scope = tenant_id ? overdue.for_tenant(tenant_id) : overdue
  swept = 0
  scope.find_each do |step|
    step.time_out!
    swept += 1
  rescue StandardError => e
    Rails.logger&.warn("[ApprovalEngine] timeout sweep skipped step #{step.id}: #{e.class}: #{e.message}")
  end
  swept
end

Instance Method Details

#actionable_by?(actor) ⇒ Boolean

True when ‘actor` may act on this step — either the assigned actor or one of their active delegates. Authorization itself stays with the host app; this is the helper they reason about.

Returns:

  • (Boolean)


65
66
67
68
69
70
# File 'app/models/approval_engine/step.rb', line 65

def actionable_by?(actor)
  return false unless pending?
  return true if assigned_actor == actor

  Delegation.active_for(assigned_actor, tenant_id: tenant_id).exists?(delegatee: actor)
end

#approve!(by:, comment: nil) ⇒ Object



104
105
106
107
108
# File 'app/models/approval_engine/step.rb', line 104

def approve!(by:, comment: nil)
  transition!(to: "approved", event: "approved", by: by, comment: comment) do
    track.advance!(self)
  end
end

#expire!(comment: nil) ⇒ Object

Honest denial when an approver never acted in time: the step becomes ‘expired` (a distinct terminal state — never “approved”, never a human “rejected”), with no human actor on the ledger. Resolves the surrounding layer consensus-aware, like a reject. Idempotent if already resolved.



144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'app/models/approval_engine/step.rb', line 144

def expire!(comment: nil)
  return self if terminal?

  transition!(to: "expired", event: "expired", by: nil, comment: comment) do
    track.advance!(self)
  end
rescue ActiveRecord::RecordInvalid
  # A human decided in the window between the guard above and the lock inside
  # transition!. Their decision stands; expire! stays the no-op it advertises.
  raise unless reload.terminal?

  self
end

#reassign!(to:, by: nil, comment: nil) ⇒ Object

Hand a stuck step to another actor without restarting the flow — the escalation path for an unresponsive approver (e.g. from on_step_timeout). Records the reassignment (old assignee as intended, reassigner as actual) and keeps the step pending in its layer. Must be pending.



162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'app/models/approval_engine/step.rb', line 162

def reassign!(to:, by: nil, comment: nil)
  track.approval.with_lock do
    reload
    unless pending?
      errors.add(:status, "must be pending to reassign (was #{status})")
      raise ActiveRecord::RecordInvalid, self
    end

    record_audit(event: "reassigned", by: by, comment: comment)
    update!(assigned_actor: to)
    emit_outbox("step.reassigned")
  end
  self
end

#reject!(by:, comment: nil) ⇒ Object



110
111
112
113
114
# File 'app/models/approval_engine/step.rb', line 110

def reject!(by:, comment: nil)
  transition!(to: "rejected", event: "rejected", by: by, comment: comment) do
    track.advance!(self)
  end
end

#request_changes!(by:, comment: nil) ⇒ Object



116
117
118
119
120
# File 'app/models/approval_engine/step.rb', line 116

def request_changes!(by:, comment: nil)
  transition!(to: "changes_requested", event: "changes_requested", by: by, comment: comment) do
    track.advance_after_changes_requested!(self)
  end
end

#targetObject

The host record this step is ultimately approving (e.g. the Invoice). Preload an inbox with ‘.includes(track: { approval: :target })`.



82
83
84
# File 'app/models/approval_engine/step.rb', line 82

def target
  track&.approval&.target
end

#terminal?Boolean

Returns:

  • (Boolean)


76
77
78
# File 'app/models/approval_engine/step.rb', line 76

def terminal?
  TERMINAL_STATUSES.include?(status)
end

#time_out!Object

The deadline passed while this step was still pending. This is a signal, not a verdict: it records a ‘timed_out` event and fires the host’s ‘on_step_timeout` callback, but does NOT decide the step — silence is never consent. The host chooses the reaction (`expire!`, escalate, remind). Fires at most once; idempotent under concurrent sweeps.



127
128
129
130
131
132
133
134
135
136
137
138
# File 'app/models/approval_engine/step.rb', line 127

def time_out!
  track.approval.with_lock do
    reload
    return self unless pending? && timed_out_at.nil?

    update!(timed_out_at: Time.current)
    record_audit(event: "timed_out", by: nil, comment: nil)
    emit_outbox("step.timed_out")
  end

  self
end

#time_to_decisionObject

Seconds a human took to decide this step — from when it became actionable (‘activated_at`) to when it was approved/rejected/changes-requested (`decided_at`). nil until decided (or for cancelled steps, never decided).



89
90
91
92
93
# File 'app/models/approval_engine/step.rb', line 89

def time_to_decision
  return unless activated_at && decided_at

  decided_at - activated_at
end

#waiting_forObject

Seconds this step has been (or was) actionable: ‘now - activated_at` while pending, `decided_at - activated_at` once resolved. nil before activation. Useful for “how long has this been sitting in someone’s queue?”.



98
99
100
101
102
# File 'app/models/approval_engine/step.rb', line 98

def waiting_for
  return unless activated_at

  (decided_at || Time.current) - activated_at
end