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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +138 -0
- data/MIT-LICENSE +20 -0
- data/README.md +249 -0
- data/app/controllers/approval_engine/application_controller.rb +10 -0
- data/app/controllers/approval_engine/approvals_controller.rb +29 -0
- data/app/helpers/approval_engine/application_helper.rb +27 -0
- data/app/jobs/approval_engine/application_job.rb +4 -0
- data/app/jobs/approval_engine/process_outbox_job.rb +100 -0
- data/app/jobs/approval_engine/timeout_sweep_job.rb +19 -0
- data/app/models/approval_engine/application_record.rb +5 -0
- data/app/models/approval_engine/approval.rb +144 -0
- data/app/models/approval_engine/approval_plan.rb +60 -0
- data/app/models/approval_engine/audit_log.rb +28 -0
- data/app/models/approval_engine/consensus.rb +37 -0
- data/app/models/approval_engine/delegation.rb +34 -0
- data/app/models/approval_engine/history.rb +62 -0
- data/app/models/approval_engine/outbox_event.rb +47 -0
- data/app/models/approval_engine/step.rb +271 -0
- data/app/models/approval_engine/template_step.rb +22 -0
- data/app/models/approval_engine/track.rb +127 -0
- data/app/models/approval_engine/track_template.rb +23 -0
- data/app/models/approval_engine/trigger_rule.rb +17 -0
- data/app/models/concerns/approval_engine/approvable.rb +183 -0
- data/app/services/approval_engine/approval_builder.rb +172 -0
- data/app/services/approval_engine/iteration_builder.rb +44 -0
- data/app/services/approval_engine/rule_evaluator.rb +119 -0
- data/app/views/approval_engine/approvals/index.html.erb +38 -0
- data/app/views/approval_engine/approvals/show.html.erb +55 -0
- data/app/views/layouts/approval_engine/dashboard.html.erb +49 -0
- data/config/routes.rb +8 -0
- data/db/migrate/20260614103434_create_approval_engine_core_tables.rb +107 -0
- data/db/migrate/20260614103435_create_approval_engine_infrastructure_tables.rb +35 -0
- data/db/migrate/20260614104809_create_approval_engine_blueprint_tables.rb +60 -0
- data/db/migrate/20260616000001_add_trigger_rule_to_approvals.rb +14 -0
- data/db/migrate/20260617000001_add_approvals_required_to_approvals.rb +11 -0
- data/db/migrate/20260617000002_add_delivery_tracking_to_outbox_events.rb +11 -0
- data/lib/approval_engine/approval_exposure.rb +66 -0
- data/lib/approval_engine/configuration.rb +67 -0
- data/lib/approval_engine/engine.rb +17 -0
- data/lib/approval_engine/model_extensions.rb +20 -0
- data/lib/approval_engine/version.rb +3 -0
- data/lib/approval_engine.rb +12 -0
- data/lib/generators/approval_engine/install/install_generator.rb +34 -0
- data/lib/generators/approval_engine/install/templates/POST_INSTALL +55 -0
- data/lib/generators/approval_engine/install/templates/approval_engine.rb +21 -0
- data/lib/generators/approval_engine/views/templates/approvals/index.html.erb +17 -0
- data/lib/generators/approval_engine/views/templates/approvals_controller.rb +32 -0
- data/lib/generators/approval_engine/views/views_generator.rb +33 -0
- 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
|