approval_engine 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +138 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +249 -0
  5. data/app/controllers/approval_engine/application_controller.rb +10 -0
  6. data/app/controllers/approval_engine/approvals_controller.rb +29 -0
  7. data/app/helpers/approval_engine/application_helper.rb +27 -0
  8. data/app/jobs/approval_engine/application_job.rb +4 -0
  9. data/app/jobs/approval_engine/process_outbox_job.rb +100 -0
  10. data/app/jobs/approval_engine/timeout_sweep_job.rb +19 -0
  11. data/app/models/approval_engine/application_record.rb +5 -0
  12. data/app/models/approval_engine/approval.rb +144 -0
  13. data/app/models/approval_engine/approval_plan.rb +60 -0
  14. data/app/models/approval_engine/audit_log.rb +28 -0
  15. data/app/models/approval_engine/consensus.rb +37 -0
  16. data/app/models/approval_engine/delegation.rb +34 -0
  17. data/app/models/approval_engine/history.rb +62 -0
  18. data/app/models/approval_engine/outbox_event.rb +47 -0
  19. data/app/models/approval_engine/step.rb +271 -0
  20. data/app/models/approval_engine/template_step.rb +22 -0
  21. data/app/models/approval_engine/track.rb +127 -0
  22. data/app/models/approval_engine/track_template.rb +23 -0
  23. data/app/models/approval_engine/trigger_rule.rb +17 -0
  24. data/app/models/concerns/approval_engine/approvable.rb +183 -0
  25. data/app/services/approval_engine/approval_builder.rb +172 -0
  26. data/app/services/approval_engine/iteration_builder.rb +44 -0
  27. data/app/services/approval_engine/rule_evaluator.rb +119 -0
  28. data/app/views/approval_engine/approvals/index.html.erb +38 -0
  29. data/app/views/approval_engine/approvals/show.html.erb +55 -0
  30. data/app/views/layouts/approval_engine/dashboard.html.erb +49 -0
  31. data/config/routes.rb +8 -0
  32. data/db/migrate/20260614103434_create_approval_engine_core_tables.rb +107 -0
  33. data/db/migrate/20260614103435_create_approval_engine_infrastructure_tables.rb +35 -0
  34. data/db/migrate/20260614104809_create_approval_engine_blueprint_tables.rb +60 -0
  35. data/db/migrate/20260616000001_add_trigger_rule_to_approvals.rb +14 -0
  36. data/db/migrate/20260617000001_add_approvals_required_to_approvals.rb +11 -0
  37. data/db/migrate/20260617000002_add_delivery_tracking_to_outbox_events.rb +11 -0
  38. data/lib/approval_engine/approval_exposure.rb +66 -0
  39. data/lib/approval_engine/configuration.rb +67 -0
  40. data/lib/approval_engine/engine.rb +17 -0
  41. data/lib/approval_engine/model_extensions.rb +20 -0
  42. data/lib/approval_engine/version.rb +3 -0
  43. data/lib/approval_engine.rb +12 -0
  44. data/lib/generators/approval_engine/install/install_generator.rb +34 -0
  45. data/lib/generators/approval_engine/install/templates/POST_INSTALL +55 -0
  46. data/lib/generators/approval_engine/install/templates/approval_engine.rb +21 -0
  47. data/lib/generators/approval_engine/views/templates/approvals/index.html.erb +17 -0
  48. data/lib/generators/approval_engine/views/templates/approvals_controller.rb +32 -0
  49. data/lib/generators/approval_engine/views/views_generator.rb +33 -0
  50. metadata +129 -0
@@ -0,0 +1,144 @@
1
+ module ApprovalEngine
2
+ # The aggregate root of one approval run: a host record + the event that
3
+ # spawned it, fanning out into one or more parallel tracks that gather per
4
+ # `approvals_required` (`:all` by default, like a layer). Progression methods
5
+ # run while the approval row is locked by the acting step, so they don't relock.
6
+ class Approval < ApplicationRecord
7
+ STATUSES = %w[pending approved rejected quarantined cancelled].freeze
8
+ TERMINAL_STATUSES = %w[approved rejected quarantined cancelled].freeze
9
+
10
+ belongs_to :target, polymorphic: true
11
+ # The rule that auto-routed this approval, when one did (nil for a manual
12
+ # run_approval!(templates:) start). Lets a host show *which* rule fired and
13
+ # why, and keeps that provenance stable even if the rule is later edited.
14
+ belongs_to :trigger_rule,
15
+ class_name: "ApprovalEngine::TriggerRule",
16
+ foreign_key: "approval_engine_trigger_rule_id",
17
+ optional: true
18
+ has_many :tracks, class_name: "ApprovalEngine::Track", foreign_key: "approval_engine_approval_id", dependent: :destroy
19
+ has_many :steps, through: :tracks
20
+
21
+ validates :tenant_id, presence: true
22
+ validates :status, inclusion: { in: STATUSES }
23
+ validate :approvals_required_is_valid
24
+
25
+ scope :pending, -> { where(status: "pending") }
26
+ scope :quarantined, -> { where(status: "quarantined") }
27
+ scope :terminal, -> { where(status: TERMINAL_STATUSES) }
28
+ scope :for_tenant, ->(tenant_id) { where(tenant_id: tenant_id) }
29
+
30
+ STATUSES.each do |state|
31
+ define_method(:"#{state}?") { status == state }
32
+ end
33
+
34
+ def terminal?
35
+ TERMINAL_STATUSES.include?(status)
36
+ end
37
+
38
+ # Convenience readers for the common single-track case. An approval always
39
+ # has at least one track, so when there's exactly one these read more
40
+ # naturally than `tracks.first`. They raise (ActiveRecord::SoleRecord
41
+ # exceeded) once the approval has fanned out, so a caller is never silently
42
+ # handed the wrong track — reach for `tracks` / `steps` then.
43
+ def track
44
+ tracks.sole
45
+ end
46
+
47
+ def step
48
+ steps.sole
49
+ end
50
+
51
+ # The step this approval is currently waiting on the longest — i.e. *where*
52
+ # it's stuck right now. The oldest still-pending step across all tracks, or
53
+ # nil if nothing is pending. `step.waiting_for` gives the elapsed seconds; the
54
+ # host decides what counts as "late" and whether to nudge or escalate.
55
+ def current_bottleneck
56
+ steps.pending.order(:activated_at).first
57
+ end
58
+
59
+ # Re-evaluate after any track resolves: approve once enough tracks have,
60
+ # fail once the count is unreachable, else wait. A layer's logic, over tracks.
61
+ def gather!
62
+ return if terminal?
63
+
64
+ case track_outcome
65
+ when :met
66
+ update!(status: "approved")
67
+ cancel_remaining_tracks!
68
+ emit_outbox("approval.approved")
69
+ when :failed
70
+ fail_gather!(reason: "required track approvals are no longer reachable")
71
+ end
72
+ end
73
+
74
+ # Withdraw an in-flight approval — the third terminal outcome beside approved
75
+ # and rejected, for when the thing being approved is voided or retracted.
76
+ # Cancels any still-open tracks/steps and fires `after_cancelled`. A no-op
77
+ # once terminal. Unlike the gather, this is a host entry point, so it takes
78
+ # its own lock.
79
+ def cancel!(reason: nil)
80
+ return self if terminal?
81
+
82
+ with_lock do
83
+ return self if terminal?
84
+
85
+ update!(status: "cancelled")
86
+ cancel_remaining_tracks!
87
+ emit_outbox("approval.cancelled", reason)
88
+ end
89
+ self
90
+ end
91
+
92
+ private
93
+
94
+ # Internal: tear the whole approval down when the gather can't be satisfied
95
+ # (called from gather! under the step's lock). No actor, no audit row — hosts
96
+ # reject through a step (Step#reject!) or withdraw via cancel!.
97
+ def fail_gather!(reason: nil)
98
+ return if terminal?
99
+
100
+ update!(status: "rejected")
101
+ cancel_remaining_tracks!
102
+ emit_outbox("approval.rejected", reason)
103
+ end
104
+
105
+ # :met / :failed / :undecided across tracks — layer consensus, one level up.
106
+ def track_outcome
107
+ group = tracks.where.not(status: "cancelled").count
108
+ return :undecided if group.zero?
109
+
110
+ approved = tracks.where(status: "approved").count
111
+ pending = tracks.where(status: "pending").count
112
+ required = Consensus.new(approvals_required).required(group)
113
+
114
+ if approved >= required then :met
115
+ elsif (approved + pending) < required then :failed
116
+ else :undecided
117
+ end
118
+ end
119
+
120
+ def approvals_required_is_valid
121
+ return if Consensus.valid?(approvals_required)
122
+
123
+ errors.add(:approvals_required, "must be :any, :all, :majority, a percentage like \"60%\", or a positive integer")
124
+ end
125
+
126
+ def cancel_remaining_tracks!
127
+ tracks.where(status: %w[pending]).find_each do |track|
128
+ track.steps.where(status: Track::OPEN_STEP_STATUSES).find_each do |step|
129
+ step.update!(status: "cancelled")
130
+ end
131
+ track.update!(status: "cancelled")
132
+ end
133
+ end
134
+
135
+ def emit_outbox(event_name, reason = nil)
136
+ OutboxEvent.create!(
137
+ tenant_id: tenant_id,
138
+ event_name: event_name,
139
+ record: self,
140
+ error_payload: reason
141
+ )
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,60 @@
1
+ module ApprovalEngine
2
+ # A read-only description of what a given event *would* trigger, produced by
3
+ # `RuleEvaluator.preview`. It writes nothing and resolves nothing eagerly — a
4
+ # UX/preview aid, not a contract (rules can change before the real run, so the
5
+ # authoritative routing still happens in `run_approval!`).
6
+ class ApprovalPlan
7
+ # One layer that would be created. Pure blueprint data — no DB rows.
8
+ PlannedStep = Struct.new(:name, :layer, :assigned_group, :approvals_required, keyword_init: true)
9
+
10
+ attr_reader :status, :template, :target, :reason
11
+
12
+ def initialize(status:, template:, target:, reason: nil)
13
+ @status = status
14
+ @template = template
15
+ @target = target
16
+ @reason = reason
17
+ end
18
+
19
+ # An approval would be spawned.
20
+ def triggered?
21
+ status == :match
22
+ end
23
+
24
+ # No rule matched — taking the action needs no approval.
25
+ def no_approval_required?
26
+ status == :no_match
27
+ end
28
+
29
+ # A rule is malformed; the real run would quarantine. `reason` says why.
30
+ def error?
31
+ status == :error
32
+ end
33
+
34
+ # The layers that would be created, in order. Pure template data.
35
+ def steps
36
+ return [] unless template
37
+
38
+ template.template_steps.ordered.map do |tpl_step|
39
+ PlannedStep.new(
40
+ name: tpl_step.name,
41
+ layer: tpl_step.layer,
42
+ assigned_group: tpl_step.assigned_group,
43
+ approvals_required: tpl_step.approvals_required
44
+ )
45
+ end
46
+ end
47
+
48
+ # Best-effort, read-only resolution of who would be assigned to a planned
49
+ # step. Returns [] if the host resolver is unavailable or raises, so a
50
+ # preview never crashes on a host-side bug.
51
+ def actors_for(planned_step)
52
+ klass = ApprovalEngine.config.actor_class_constant
53
+ return [] unless klass.respond_to?(:resolve_approval_group)
54
+
55
+ Array(klass.resolve_approval_group(planned_step.assigned_group, target)).compact
56
+ rescue StandardError
57
+ []
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,28 @@
1
+ module ApprovalEngine
2
+ # An append-only record of a single ledger event. Audit rows are write-once:
3
+ # they capture the *intended* actor (who the step was assigned to) alongside
4
+ # the *actual* actor (who acted, possibly a delegate), giving compliance teams
5
+ # a tamper-evident trail of every decision.
6
+ class AuditLog < ApplicationRecord
7
+ belongs_to :step, class_name: "ApprovalEngine::Step", foreign_key: "approval_engine_step_id"
8
+ belongs_to :intended_actor, polymorphic: true, optional: true
9
+ # Optional: a system event (e.g. `timed_out`/`expired`) has no human actor.
10
+ belongs_to :actual_actor, polymorphic: true, optional: true
11
+
12
+ validates :tenant_id, :event, presence: true
13
+
14
+ scope :for_tenant, ->(tenant_id) { where(tenant_id: tenant_id) }
15
+
16
+ # True when the acting actor differed from the assigned one — i.e. a
17
+ # delegate approved on someone's behalf. A system event (no actual actor) is
18
+ # never a proxy.
19
+ def by_proxy?
20
+ actual_actor_id.present? && intended_actor_id.present? && intended_actor != actual_actor
21
+ end
22
+
23
+ # The ledger never rewrites history.
24
+ def readonly?
25
+ persisted?
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,37 @@
1
+ module ApprovalEngine
2
+ # Value object for a layer's consensus condition. It parses the declarative
3
+ # `approvals_required` spec and computes how many approvals a group of a given
4
+ # size needs:
5
+ #
6
+ # :any -> 1
7
+ # :all -> everyone in the group
8
+ # :majority -> more than half
9
+ # "60%" -> that proportion of the group (at least 1)
10
+ # 2 -> an exact count
11
+ #
12
+ # Relative specs are resolved against the *live* group, so authors never have
13
+ # to know team sizes — "majority of whoever is on the team" just works, and
14
+ # adapts as members are added or drop out.
15
+ class Consensus
16
+ FORMAT = /\A([1-9][0-9]*%?|any|all|majority)\z/
17
+
18
+ def self.valid?(spec)
19
+ FORMAT.match?(spec.to_s)
20
+ end
21
+
22
+ def initialize(spec)
23
+ @spec = spec.to_s
24
+ end
25
+
26
+ # How many approvals are needed out of a group of `group_size`.
27
+ def required(group_size)
28
+ case @spec
29
+ when "any" then 1
30
+ when "all" then group_size
31
+ when "majority" then (group_size / 2) + 1
32
+ when /%\z/ then [ (group_size * @spec.to_i / 100.0).ceil, 1 ].max
33
+ else @spec.to_i
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,34 @@
1
+ module ApprovalEngine
2
+ # A time-bound proxy lease: while it is active, the delegatee may act on steps
3
+ # assigned to the delegator (e.g. covering approvals during a vacation). The
4
+ # ledger still records the delegator as the *intended* actor and the delegatee
5
+ # as the *actual* actor, so the proxy is always visible in the audit trail.
6
+ class Delegation < ApplicationRecord
7
+ belongs_to :delegator, polymorphic: true
8
+ belongs_to :delegatee, polymorphic: true
9
+
10
+ validates :tenant_id, presence: true
11
+ validates :starts_at, :ends_at, presence: true
12
+ validate :ends_after_start
13
+
14
+ scope :for_tenant, ->(tenant_id) { where(tenant_id: tenant_id) }
15
+
16
+ # Currently-effective delegations as of `at` (defaults to now).
17
+ scope :in_effect, ->(at = Time.current) { where(active: true).where(starts_at: ..at).where(ends_at: at..) }
18
+
19
+ # Active delegations *from* a given delegator, optionally tenant-scoped.
20
+ def self.active_for(delegator, tenant_id: nil, at: Time.current)
21
+ scope = in_effect(at).where(delegator: delegator)
22
+ tenant_id ? scope.for_tenant(tenant_id) : scope
23
+ end
24
+
25
+ private
26
+
27
+ def ends_after_start
28
+ return if starts_at.blank? || ends_at.blank?
29
+ return if ends_at > starts_at
30
+
31
+ errors.add(:ends_at, "must be after starts_at")
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,62 @@
1
+ module ApprovalEngine
2
+ # A read-only view of everything a record has gone through: every approval it
3
+ # spawned, the track/step tree beneath each, and a flat chronological
4
+ # timeline of the actions taken (with actors and comments).
5
+ #
6
+ # It assembles the data; *who* may see it and *how* it's rendered is the host's
7
+ # call — wrap it in your own authorization and UI.
8
+ #
9
+ # history = invoice.approval_history
10
+ # history.approvals # => newest-first, tree preloaded (no N+1)
11
+ # history.events # => chronological audit entries across everything
12
+ class History
13
+ def self.for(record)
14
+ new(record)
15
+ end
16
+
17
+ def initialize(record)
18
+ @record = record
19
+ end
20
+
21
+ # Every approval for the record, newest first, with tracks, steps and
22
+ # their assigned actors eager-loaded so traversal doesn't fan out into N+1
23
+ # queries.
24
+ def approvals
25
+ @approvals ||= Approval.where(target: @record)
26
+ .includes(tracks: { steps: :assigned_actor })
27
+ .order(created_at: :desc)
28
+ .to_a
29
+ end
30
+
31
+ def latest
32
+ approvals.first
33
+ end
34
+
35
+ def empty?
36
+ approvals.empty?
37
+ end
38
+
39
+ # The "what happened" narrative: every step action (approved / rejected /
40
+ # changes_requested) across all of this record's approvals and iterations,
41
+ # oldest first. Queried (and ordered + capped) in the database rather than by
42
+ # walking the whole tree in Ruby, with the polymorphic actors preloaded.
43
+ # Each entry is an AuditLog, so the host can read its event, actors (intended
44
+ # vs actual), comment, timestamp and step context.
45
+ def events(limit: 500)
46
+ AuditLog.where(approval_engine_step_id: step_ids)
47
+ .preload(:actual_actor, :intended_actor, step: :assigned_actor)
48
+ .order(:created_at)
49
+ .limit(limit)
50
+ end
51
+
52
+ private
53
+
54
+ # Step ids belonging to this record's approvals, as a subquery so the events
55
+ # query stays a single statement (no per-row joins, no eager-load surprises).
56
+ def step_ids
57
+ Step.joins(track: :approval)
58
+ .where(approval_engine_approvals: { target_type: @record.class.polymorphic_name, target_id: @record.id })
59
+ .select(:id)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,47 @@
1
+ module ApprovalEngine
2
+ # A row in the transactional outbox. It is written in the *same* transaction
3
+ # as the state change that produced it, then relayed asynchronously — so a
4
+ # crashing mailer or a down payment API can never roll back an approval, and
5
+ # no side-effect is ever silently lost.
6
+ class OutboxEvent < ApplicationRecord
7
+ # `record` is always an engine row (Approval or Step, UUID-keyed), never a
8
+ # host record. The column is NOT NULL — every event is created with one — but
9
+ # `optional: true` relaxes the load-time check so an event whose record was
10
+ # destroyed before relay can be retired instead of poisoning the queue.
11
+ belongs_to :record, polymorphic: true, optional: true
12
+
13
+ validates :tenant_id, :event_name, presence: true
14
+
15
+ scope :unprocessed, -> { where(processed: false) }
16
+ # Dead letters: delivery retries were exhausted. Excluded from drain! so a
17
+ # permanently-failing callback isn't resurrected forever; surfaced here for
18
+ # ops to inspect and replay (clear failed_at to let drain! pick it up again).
19
+ scope :failed, -> { where.not(failed_at: nil) }
20
+
21
+ # Relay the event once the producing transaction has safely committed.
22
+ after_create_commit :enqueue_relay
23
+
24
+ # Safety net for events whose relay job was lost (e.g. the process died
25
+ # between commit and enqueue). Wire this to a periodic ActiveJob/cron.
26
+ # `older_than` skips freshly-created events whose relay is likely still
27
+ # in-flight, so draining never double-enqueues a healthy event; dead letters
28
+ # are skipped so exhausted poison events aren't retried in perpetuity.
29
+ def self.drain!(older_than: 1.minute, limit: 1000)
30
+ unprocessed.where(failed_at: nil)
31
+ .where(created_at: ..older_than.ago)
32
+ .order(:created_at).limit(limit).pluck(:id).each do |id|
33
+ ProcessOutboxJob.perform_later(id)
34
+ end
35
+ end
36
+
37
+ def mark_processed!
38
+ update!(processed: true, processed_at: Time.current, error_payload: nil, delivery_error: nil)
39
+ end
40
+
41
+ private
42
+
43
+ def enqueue_relay
44
+ ProcessOutboxJob.perform_later(id)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,271 @@
1
+ module ApprovalEngine
2
+ # A single node in the immutable approval ledger.
3
+ #
4
+ # Steps move strictly forward through their lifecycle — they are never reset
5
+ # backwards. Requesting changes appends a fresh iteration instead (see
6
+ # IterationBuilder), preserving the historical truth of every attempt.
7
+ #
8
+ # The bang methods (#approve!, #reject!, #request_changes!) take a
9
+ # pessimistic lock, write an audit row, advance the surrounding track, and
10
+ # drop a transactional-outbox event — all in one transaction — so concurrent
11
+ # "Approve" clicks can never double-resolve a step.
12
+ class Step < ApplicationRecord
13
+ # Lifecycle. `waiting` steps belong to a future layer and are not yet
14
+ # actionable; they are activated to `pending` once the prior layer resolves.
15
+ STATUSES = %w[waiting pending approved rejected changes_requested expired cancelled].freeze
16
+ TERMINAL_STATUSES = %w[approved rejected changes_requested expired cancelled].freeze
17
+
18
+ # Allowed forward transitions. Anything else is rejected to keep the ledger
19
+ # append-only. `expired` is a distinct terminal state — a deadline lapse is
20
+ # never recorded as an approval or a human rejection.
21
+ TRANSITIONS = {
22
+ "waiting" => %w[pending cancelled],
23
+ "pending" => %w[approved rejected changes_requested expired cancelled]
24
+ }.freeze
25
+
26
+ belongs_to :track, class_name: "ApprovalEngine::Track", foreign_key: "approval_engine_track_id"
27
+ belongs_to :assigned_actor, polymorphic: true
28
+ has_many :audit_logs, class_name: "ApprovalEngine::AuditLog", foreign_key: "approval_engine_step_id", dependent: :destroy
29
+
30
+ has_one :approval, through: :track
31
+
32
+ validates :tenant_id, presence: true
33
+ validates :status, inclusion: { in: STATUSES }
34
+ validates :layer, :iteration, numericality: { greater_than: 0 }
35
+ validate :approvals_required_is_valid
36
+
37
+ before_update :guard_immutable_transition
38
+ before_save :stamp_timing
39
+
40
+ scope :waiting, -> { where(status: "waiting") }
41
+ scope :pending, -> { where(status: "pending") }
42
+ scope :approved, -> { where(status: "approved") }
43
+ scope :for_iteration, ->(iteration) { where(iteration: iteration) }
44
+ scope :for_layer, ->(layer) { where(layer: layer) }
45
+ scope :for_tenant, ->(tenant_id) { where(tenant_id: tenant_id) }
46
+ # Pending steps whose deadline has passed and that haven't fired yet — the
47
+ # set the timeout sweep acts on. Each step times out at most once.
48
+ scope :overdue, ->(as_of = Time.current) {
49
+ pending.where(timed_out_at: nil).where.not(timeout_at: nil).where("timeout_at <= ?", as_of)
50
+ }
51
+
52
+ # An approver's inbox: pending steps the actor may act on — assigned to them
53
+ # directly *plus* those they cover via an active delegation. The scope form
54
+ # of `#actionable_by?`. Chain `.count`, `.order`, pagination, etc.
55
+ scope :actionable_by, ->(actor, tenant: nil) {
56
+ type = actor.class.polymorphic_name
57
+ delegated_ids = Delegation.in_effect.where(delegatee: actor, delegator_type: type).pluck(:delegator_id)
58
+ rel = pending.where(assigned_actor_type: type, assigned_actor_id: [ actor.id, *delegated_ids ])
59
+ tenant ? rel.for_tenant(tenant) : rel
60
+ }
61
+
62
+ # True when `actor` may act on this step — either the assigned actor or one
63
+ # of their active delegates. Authorization itself stays with the host app;
64
+ # this is the helper they reason about.
65
+ def actionable_by?(actor)
66
+ return false unless pending?
67
+ return true if assigned_actor == actor
68
+
69
+ Delegation.active_for(assigned_actor, tenant_id: tenant_id).exists?(delegatee: actor)
70
+ end
71
+
72
+ STATUSES.each do |state|
73
+ define_method(:"#{state}?") { status == state }
74
+ end
75
+
76
+ def terminal?
77
+ TERMINAL_STATUSES.include?(status)
78
+ end
79
+
80
+ # The host record this step is ultimately approving (e.g. the Invoice).
81
+ # Preload an inbox with `.includes(track: { approval: :target })`.
82
+ def target
83
+ track&.approval&.target
84
+ end
85
+
86
+ # Seconds a human took to decide this step — from when it became actionable
87
+ # (`activated_at`) to when it was approved/rejected/changes-requested
88
+ # (`decided_at`). nil until decided (or for cancelled steps, never decided).
89
+ def time_to_decision
90
+ return unless activated_at && decided_at
91
+
92
+ decided_at - activated_at
93
+ end
94
+
95
+ # Seconds this step has been (or was) actionable: `now - activated_at` while
96
+ # pending, `decided_at - activated_at` once resolved. nil before activation.
97
+ # Useful for "how long has this been sitting in someone's queue?".
98
+ def waiting_for
99
+ return unless activated_at
100
+
101
+ (decided_at || Time.current) - activated_at
102
+ end
103
+
104
+ def approve!(by:, comment: nil)
105
+ transition!(to: "approved", event: "approved", by: by, comment: comment) do
106
+ track.advance!(self)
107
+ end
108
+ end
109
+
110
+ def reject!(by:, comment: nil)
111
+ transition!(to: "rejected", event: "rejected", by: by, comment: comment) do
112
+ track.advance!(self)
113
+ end
114
+ end
115
+
116
+ def request_changes!(by:, comment: nil)
117
+ transition!(to: "changes_requested", event: "changes_requested", by: by, comment: comment) do
118
+ track.advance_after_changes_requested!(self)
119
+ end
120
+ end
121
+
122
+ # The deadline passed while this step was still pending. This is a *signal*,
123
+ # not a verdict: it records a `timed_out` event and fires the host's
124
+ # `on_step_timeout` callback, but does NOT decide the step — silence is never
125
+ # consent. The host chooses the reaction (`expire!`, escalate, remind). Fires
126
+ # at most once; idempotent under concurrent sweeps.
127
+ def time_out!
128
+ track.approval.with_lock do
129
+ reload
130
+ return self unless pending? && timed_out_at.nil?
131
+
132
+ update!(timed_out_at: Time.current)
133
+ record_audit(event: "timed_out", by: nil, comment: nil)
134
+ emit_outbox("step.timed_out")
135
+ end
136
+
137
+ self
138
+ end
139
+
140
+ # Honest denial when an approver never acted in time: the step becomes
141
+ # `expired` (a distinct terminal state — never "approved", never a human
142
+ # "rejected"), with no human actor on the ledger. Resolves the surrounding
143
+ # layer consensus-aware, like a reject. Idempotent if already resolved.
144
+ def expire!(comment: nil)
145
+ return self if terminal?
146
+
147
+ transition!(to: "expired", event: "expired", by: nil, comment: comment) do
148
+ track.advance!(self)
149
+ end
150
+ rescue ActiveRecord::RecordInvalid
151
+ # A human decided in the window between the guard above and the lock inside
152
+ # transition!. Their decision stands; expire! stays the no-op it advertises.
153
+ raise unless reload.terminal?
154
+
155
+ self
156
+ end
157
+
158
+ # Hand a stuck step to another actor without restarting the flow — the
159
+ # escalation path for an unresponsive approver (e.g. from on_step_timeout).
160
+ # Records the reassignment (old assignee as intended, reassigner as actual)
161
+ # and keeps the step pending in its layer. Must be pending.
162
+ def reassign!(to:, by: nil, comment: nil)
163
+ track.approval.with_lock do
164
+ reload
165
+ unless pending?
166
+ errors.add(:status, "must be pending to reassign (was #{status})")
167
+ raise ActiveRecord::RecordInvalid, self
168
+ end
169
+
170
+ record_audit(event: "reassigned", by: by, comment: comment)
171
+ update!(assigned_actor: to)
172
+ emit_outbox("step.reassigned")
173
+ end
174
+ self
175
+ end
176
+
177
+ # Fire the timeout signal for every overdue step. Safe to run as often as you
178
+ # like (each step times out once); scope to a tenant in multi-tenant cron.
179
+ # Returns the number swept. One step raising (lock contention, a concurrently
180
+ # destroyed approval) is logged and skipped, so it can't starve the rest of
181
+ # the batch — the next sweep retries it (idempotent). TimeoutSweepJob wraps
182
+ # this for background runs.
183
+ def self.sweep_timeouts!(tenant_id: nil)
184
+ scope = tenant_id ? overdue.for_tenant(tenant_id) : overdue
185
+ swept = 0
186
+ scope.find_each do |step|
187
+ step.time_out!
188
+ swept += 1
189
+ rescue StandardError => e
190
+ Rails.logger&.warn("[ApprovalEngine] timeout sweep skipped step #{step.id}: #{e.class}: #{e.message}")
191
+ end
192
+ swept
193
+ end
194
+
195
+ private
196
+
197
+ # The shared transition pipeline. We lock the *approval* — the aggregate
198
+ # root — before touching anything, so every transition within an approval is
199
+ # fully serialized. That makes double-approvals impossible and lets a layer
200
+ # safely cancel its sibling steps without deadlocking against them.
201
+ #
202
+ # The yielded block advances the track/approval synchronously, so the
203
+ # ledger is always internally consistent by the time the transaction
204
+ # commits. External side-effects are deferred to the transactional outbox.
205
+ def transition!(to:, event:, by:, comment:)
206
+ track.approval.with_lock do
207
+ reload
208
+
209
+ unless pending?
210
+ errors.add(:status, "must be pending to be #{event} (was #{status})")
211
+ raise ActiveRecord::RecordInvalid, self
212
+ end
213
+
214
+ update!(status: to)
215
+ record_audit(event: event, by: by, comment: comment)
216
+ yield if block_given?
217
+ emit_outbox("step.#{event}")
218
+ end
219
+
220
+ self
221
+ end
222
+
223
+ def record_audit(event:, by:, comment:)
224
+ audit_logs.create!(
225
+ tenant_id: tenant_id,
226
+ event: event,
227
+ intended_actor: assigned_actor,
228
+ actual_actor: by,
229
+ comment: comment
230
+ )
231
+ end
232
+
233
+ def emit_outbox(event_name)
234
+ OutboxEvent.create!(tenant_id: tenant_id, event_name: event_name, record: self)
235
+ end
236
+
237
+ def guard_immutable_transition
238
+ return unless will_save_change_to_status?
239
+
240
+ from, to = status_change_to_be_saved
241
+ allowed = TRANSITIONS.fetch(from, [])
242
+ return if allowed.include?(to)
243
+
244
+ errors.add(:status, "cannot transition from #{from} to #{to}")
245
+ throw :abort
246
+ end
247
+
248
+ def approvals_required_is_valid
249
+ return if Consensus.valid?(approvals_required)
250
+
251
+ errors.add(:approvals_required, "must be :any, :all, :majority, a percentage like \"60%\", or a positive integer")
252
+ end
253
+
254
+ # Stamp the cycle-time facts wherever a step's status changes — at build, on
255
+ # waiting->pending activation, and on a human decision — so latency reporting
256
+ # never has to re-derive timing from the audit log. `||=` keeps the first
257
+ # value, so re-saves don't move the clock. Cancelled steps were never decided,
258
+ # so they stay decided_at: nil.
259
+ DECISION_STATUSES = %w[approved rejected changes_requested].freeze
260
+
261
+ def stamp_timing
262
+ if status == "pending"
263
+ self.activated_at ||= Time.current
264
+ # Deadline = actionable + the SLA window. `||=` lets the host set an
265
+ # absolute `timeout_at` directly (e.g. computed against business hours).
266
+ self.timeout_at ||= activated_at + timeout_after if timeout_after
267
+ end
268
+ self.decided_at ||= Time.current if DECISION_STATUSES.include?(status)
269
+ end
270
+ end
271
+ end