Class: ApprovalEngine::Step
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- ApprovalEngine::Step
- 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
-
.sweep_timeouts!(tenant_id: nil) ⇒ Object
Fire the timeout signal for every overdue step.
Instance Method Summary collapse
-
#actionable_by?(actor) ⇒ Boolean
True when ‘actor` may act on this step — either the assigned actor or one of their active delegates.
- #approve!(by:, comment: nil) ⇒ Object
-
#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.
-
#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).
- #reject!(by:, comment: nil) ⇒ Object
- #request_changes!(by:, comment: nil) ⇒ Object
-
#target ⇒ Object
The host record this step is ultimately approving (e.g. the Invoice).
- #terminal? ⇒ Boolean
-
#time_out! ⇒ Object
The deadline passed while this step was still pending.
-
#time_to_decision ⇒ Object
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`).
-
#waiting_for ⇒ Object
Seconds this step has been (or was) actionable: ‘now - activated_at` while pending, `decided_at - activated_at` once resolved.
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.}") 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.
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 |
#target ⇒ Object
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
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_decision ⇒ Object
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_for ⇒ Object
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 |