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,22 @@
|
|
|
1
|
+
module ApprovalEngine
|
|
2
|
+
# A blueprint for a layer of approval. When an approval is built, each template
|
|
3
|
+
# step is expanded into one concrete Step per resolved actor, all sharing the
|
|
4
|
+
# layer's consensus condition (`approvals_required`).
|
|
5
|
+
class TemplateStep < ApplicationRecord
|
|
6
|
+
belongs_to :track_template, class_name: "ApprovalEngine::TrackTemplate", foreign_key: "approval_engine_track_template_id"
|
|
7
|
+
|
|
8
|
+
validates :name, :assigned_group, presence: true
|
|
9
|
+
validates :layer, numericality: { greater_than: 0 }
|
|
10
|
+
validate :approvals_required_is_valid
|
|
11
|
+
|
|
12
|
+
scope :ordered, -> { order(:layer) }
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def approvals_required_is_valid
|
|
17
|
+
return if Consensus.valid?(approvals_required)
|
|
18
|
+
|
|
19
|
+
errors.add(:approvals_required, "must be :any, :all, :majority, a percentage like \"60%\", or a positive integer")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
module ApprovalEngine
|
|
2
|
+
# One track of an approval (e.g. "Finance" or "Legal"). A track holds
|
|
3
|
+
# ordered layers of steps; a layer resolves once its consensus policy is met,
|
|
4
|
+
# which activates the next layer or completes the track.
|
|
5
|
+
#
|
|
6
|
+
# All progression happens synchronously inside the acting step's lock, so a
|
|
7
|
+
# track is never observed in a half-advanced state.
|
|
8
|
+
class Track < ApplicationRecord
|
|
9
|
+
STATUSES = %w[pending approved rejected cancelled].freeze
|
|
10
|
+
OPEN_STEP_STATUSES = %w[waiting pending].freeze
|
|
11
|
+
|
|
12
|
+
belongs_to :approval, class_name: "ApprovalEngine::Approval", foreign_key: "approval_engine_approval_id"
|
|
13
|
+
has_many :steps, class_name: "ApprovalEngine::Step", foreign_key: "approval_engine_track_id", dependent: :destroy
|
|
14
|
+
|
|
15
|
+
validates :tenant_id, :name, presence: true
|
|
16
|
+
validates :status, inclusion: { in: STATUSES }
|
|
17
|
+
|
|
18
|
+
STATUSES.each do |state|
|
|
19
|
+
define_method(:"#{state}?") { status == state }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A step was approved or rejected: re-evaluate its layer and short-circuit.
|
|
23
|
+
# The layer resolves the moment its consensus is *met*, fails the moment its
|
|
24
|
+
# consensus is *unreachable*, and otherwise waits for more votes. Both
|
|
25
|
+
# approve! and reject! funnel through here so rejection respects the layer's
|
|
26
|
+
# consensus policy instead of being a blanket veto.
|
|
27
|
+
def advance!(step)
|
|
28
|
+
layer_steps = steps.for_iteration(step.iteration).for_layer(step.layer)
|
|
29
|
+
|
|
30
|
+
case layer_outcome(layer_steps)
|
|
31
|
+
when :met
|
|
32
|
+
cancel_steps(layer_steps.pending) # remaining votes are no longer needed
|
|
33
|
+
activate_next_layer(step) || complete!
|
|
34
|
+
when :failed
|
|
35
|
+
fail!
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# An approver requested changes: cancel this iteration's open work and
|
|
40
|
+
# append a fresh iteration. The track stays pending.
|
|
41
|
+
def advance_after_changes_requested!(step)
|
|
42
|
+
cancel_open_steps!
|
|
43
|
+
IterationBuilder.build_next_iteration!(step)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The live consensus tally for one layer (within an iteration) — the same
|
|
47
|
+
# facts `advance!` decides on, exposed as a read so a host UI can show
|
|
48
|
+
# "N of M approved" and *why* a layer is met/failed/undecided without
|
|
49
|
+
# re-deriving the consensus math (which only the engine should own).
|
|
50
|
+
#
|
|
51
|
+
# track.layer_tally(1)
|
|
52
|
+
# # => { required: 2, approved: 1, rejected: 0, pending: 2, waiting: 0,
|
|
53
|
+
# # group_size: 3, outcome: :undecided }
|
|
54
|
+
#
|
|
55
|
+
# Defaults to the track's latest iteration. A layer that hasn't opened yet
|
|
56
|
+
# (all steps still `waiting`) reads as `:undecided`, not `:failed`. Returns a
|
|
57
|
+
# zeroed `:undecided` tally for a layer that has no steps.
|
|
58
|
+
def layer_tally(layer, iteration: steps.maximum(:iteration))
|
|
59
|
+
tally_for(steps.for_iteration(iteration).for_layer(layer))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# :met, :failed, or :undecided for a layer — just the verdict slice of the
|
|
65
|
+
# full tally. Both approve! and reject! funnel through advance! → here, so
|
|
66
|
+
# rejection respects the layer's consensus policy instead of being a veto.
|
|
67
|
+
def layer_outcome(layer_steps)
|
|
68
|
+
tally_for(layer_steps)[:outcome]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Met once `required` approvals are in; failed only once unreachable — even
|
|
72
|
+
# the steps still to come (pending + not-yet-opened `waiting`) couldn't reach
|
|
73
|
+
# it. advance! only sees the active layer, where waiting is 0, so it's unchanged.
|
|
74
|
+
def tally_for(layer_steps)
|
|
75
|
+
spec = layer_steps.first&.approvals_required
|
|
76
|
+
return { required: 0, approved: 0, rejected: 0, pending: 0, waiting: 0, group_size: 0, outcome: :undecided } if spec.nil?
|
|
77
|
+
|
|
78
|
+
approved = layer_steps.approved.count
|
|
79
|
+
pending = layer_steps.pending.count
|
|
80
|
+
waiting = layer_steps.waiting.count
|
|
81
|
+
rejected = layer_steps.where(status: "rejected").count
|
|
82
|
+
group = layer_steps.where.not(status: "cancelled").count
|
|
83
|
+
required = Consensus.new(spec).required(group)
|
|
84
|
+
|
|
85
|
+
outcome =
|
|
86
|
+
if approved >= required then :met
|
|
87
|
+
elsif (approved + pending + waiting) < required then :failed
|
|
88
|
+
else :undecided
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
{ required: required, approved: approved, rejected: rejected, pending: pending, waiting: waiting, group_size: group, outcome: outcome }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# This track can't reach consensus: reject it, then let the approval
|
|
95
|
+
# re-gather (one rejected track no longer forces the whole approval down).
|
|
96
|
+
def fail!
|
|
97
|
+
update!(status: "rejected")
|
|
98
|
+
cancel_open_steps!
|
|
99
|
+
approval.gather!
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Activate the next *existing* layer above this one — not blindly layer + 1 —
|
|
103
|
+
# so non-contiguous layer numbers (1, 3, 5…) don't strand a waiting layer or
|
|
104
|
+
# complete the track prematurely.
|
|
105
|
+
def activate_next_layer(step)
|
|
106
|
+
scope = steps.for_iteration(step.iteration).waiting.where("layer > ?", step.layer)
|
|
107
|
+
next_layer = scope.minimum(:layer)
|
|
108
|
+
return false unless next_layer
|
|
109
|
+
|
|
110
|
+
scope.where(layer: next_layer).find_each { |s| s.update!(status: "pending") }
|
|
111
|
+
true
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def complete!
|
|
115
|
+
update!(status: "approved")
|
|
116
|
+
approval.gather!
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def cancel_open_steps!
|
|
120
|
+
cancel_steps(steps.where(status: OPEN_STEP_STATUSES))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def cancel_steps(relation)
|
|
124
|
+
relation.find_each { |s| s.update!(status: "cancelled") }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module ApprovalEngine
|
|
2
|
+
# The reusable blueprint an approval is stamped from. Authored by SaaS admins
|
|
3
|
+
# (often through a UI) and selected at runtime by the matching TriggerRule.
|
|
4
|
+
class TrackTemplate < ApplicationRecord
|
|
5
|
+
STATUSES = %w[draft active archived].freeze
|
|
6
|
+
|
|
7
|
+
has_many :template_steps,
|
|
8
|
+
-> { ordered },
|
|
9
|
+
class_name: "ApprovalEngine::TemplateStep",
|
|
10
|
+
foreign_key: "approval_engine_track_template_id",
|
|
11
|
+
dependent: :destroy
|
|
12
|
+
has_many :trigger_rules,
|
|
13
|
+
class_name: "ApprovalEngine::TriggerRule",
|
|
14
|
+
foreign_key: "approval_engine_track_template_id",
|
|
15
|
+
dependent: :destroy
|
|
16
|
+
|
|
17
|
+
validates :tenant_id, :name, presence: true
|
|
18
|
+
validates :status, inclusion: { in: STATUSES }
|
|
19
|
+
|
|
20
|
+
scope :active, -> { where(status: "active") }
|
|
21
|
+
scope :for_tenant, ->(tenant_id) { where(tenant_id: tenant_id) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module ApprovalEngine
|
|
2
|
+
# A tenant-scoped routing rule. Its `condition` is a JSON Logic AST stored in
|
|
3
|
+
# JSONB; the RuleEvaluator applies it to a host payload and, on the
|
|
4
|
+
# highest-priority match, spawns the linked template's approval.
|
|
5
|
+
class TriggerRule < ApplicationRecord
|
|
6
|
+
belongs_to :track_template, class_name: "ApprovalEngine::TrackTemplate", foreign_key: "approval_engine_track_template_id"
|
|
7
|
+
|
|
8
|
+
validates :tenant_id, :event_name, presence: true
|
|
9
|
+
validates :condition, presence: true
|
|
10
|
+
validates :priority, numericality: { only_integer: true }
|
|
11
|
+
|
|
12
|
+
scope :active, -> { where(active: true) }
|
|
13
|
+
scope :for_event, ->(event) { where(event_name: event) }
|
|
14
|
+
scope :for_tenant, ->(tenant_id) { where(tenant_id: tenant_id) }
|
|
15
|
+
scope :by_priority, -> { order(priority: :desc) }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
module ApprovalEngine
|
|
2
|
+
# Mixed into a host model by the `has_approvals` macro. Gives the
|
|
3
|
+
# model its approval association, the `exposes_for_approval` anti-corruption
|
|
4
|
+
# DSL, and the trigger that spawns an approval for a domain event.
|
|
5
|
+
#
|
|
6
|
+
# class Invoice < ApplicationRecord
|
|
7
|
+
# has_approvals
|
|
8
|
+
#
|
|
9
|
+
# exposes_for_approval do
|
|
10
|
+
# attribute :amount, type: :decimal
|
|
11
|
+
# attribute :department, type: :string, source: ->(i) { i.department.name }
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# def after_approved
|
|
15
|
+
# PaymentService.disburse_funds!(self)
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# By default, creating the record evaluates the tenant's rules and spawns the
|
|
20
|
+
# matching approval. Override `trigger_approval?` to gate that, or
|
|
21
|
+
# pass `has_approvals(on: [])` to opt out and trigger manually with
|
|
22
|
+
# `record.run_approval!(event:)`.
|
|
23
|
+
module Approvable
|
|
24
|
+
extend ActiveSupport::Concern
|
|
25
|
+
|
|
26
|
+
# The ActiveRecord lifecycle events the `on:` option understands, mapped to
|
|
27
|
+
# the conventional suffix of the event they route (create -> "<model>.created").
|
|
28
|
+
# Add a lifecycle here and it's wired automatically — no new methods.
|
|
29
|
+
LIFECYCLE_EVENTS = { create: "created", update: "updated", destroy: "destroyed" }.freeze
|
|
30
|
+
|
|
31
|
+
included do
|
|
32
|
+
has_many :approvals,
|
|
33
|
+
class_name: "ApprovalEngine::Approval",
|
|
34
|
+
as: :target,
|
|
35
|
+
dependent: :destroy
|
|
36
|
+
|
|
37
|
+
# Per-class exposure, inherited and dup-on-write so subclasses can extend
|
|
38
|
+
# a parent's declaration without mutating it.
|
|
39
|
+
class_attribute :approval_exposure,
|
|
40
|
+
instance_writer: false,
|
|
41
|
+
default: ApprovalEngine::ApprovalExposure.new
|
|
42
|
+
|
|
43
|
+
# Which lifecycle events auto-trigger routing. Set by the macro.
|
|
44
|
+
class_attribute :approval_trigger_events, instance_writer: false, default: [].freeze
|
|
45
|
+
|
|
46
|
+
# One generic registration for every lifecycle — no method-per-event.
|
|
47
|
+
LIFECYCLE_EVENTS.each_key do |lifecycle|
|
|
48
|
+
after_commit(on: lifecycle, if: -> { auto_trigger_approval?(lifecycle) }) do
|
|
49
|
+
run_approval!(event: self.class.approval_event_name(lifecycle))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class_methods do
|
|
55
|
+
# Declare the whitelisted surface the rules engine may read. Additive: can
|
|
56
|
+
# be called more than once, and subclasses extend rather than replace.
|
|
57
|
+
def exposes_for_approval(&block)
|
|
58
|
+
exposure = approval_exposure.dup
|
|
59
|
+
exposure.instance_eval(&block) if block
|
|
60
|
+
self.approval_exposure = exposure
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The conventional event name an auto-trigger emits for a lifecycle
|
|
64
|
+
# (:create, :update, :destroy). Use it when defining rules so the rule's
|
|
65
|
+
# event_name can never drift from what the engine actually fires:
|
|
66
|
+
#
|
|
67
|
+
# trigger_rules.create!(event_name: Invoice.approval_event_name(:create), ...)
|
|
68
|
+
#
|
|
69
|
+
# Raises for an unknown lifecycle rather than producing a silent typo.
|
|
70
|
+
def approval_event_name(lifecycle)
|
|
71
|
+
"#{model_name.element}.#{LIFECYCLE_EVENTS.fetch(lifecycle)}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The flat, string-keyed payload derived from `exposes_for_approval`.
|
|
76
|
+
def serialize_for_approval
|
|
77
|
+
approval_exposure.serialize(self)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Start an approval, two ways (pass exactly one):
|
|
81
|
+
#
|
|
82
|
+
# run_approval!(event: "invoice.created") # engine routes by rules
|
|
83
|
+
# run_approval!(templates: [finance, legal]) # you choose explicitly
|
|
84
|
+
#
|
|
85
|
+
# With `event:`, the tenant's rules are evaluated and the highest-priority
|
|
86
|
+
# match is spawned — returns the approval, a quarantine approval on a rule
|
|
87
|
+
# failure, or nil when nothing matched (or the tenant can't be resolved).
|
|
88
|
+
#
|
|
89
|
+
# With `templates:`, rule evaluation is skipped and exactly those templates
|
|
90
|
+
# are started (several become parallel tracks of one approval); always
|
|
91
|
+
# returns the spawned approval. Pair with `approval_candidates` to let a user
|
|
92
|
+
# choose instead of the engine. `approvals_required` is the gather consensus
|
|
93
|
+
# across those tracks (default `:all`).
|
|
94
|
+
def run_approval!(event: nil, templates: nil, approvals_required: "all", tenant_id: approval_tenant_id)
|
|
95
|
+
raise ArgumentError, "pass either event: or templates:, not both" if event && templates
|
|
96
|
+
|
|
97
|
+
if templates
|
|
98
|
+
ApprovalEngine::ApprovalBuilder.build_parallel!(templates: Array(templates), target: self, approvals_required: approvals_required)
|
|
99
|
+
elsif event
|
|
100
|
+
return if tenant_id.nil?
|
|
101
|
+
|
|
102
|
+
ApprovalEngine::RuleEvaluator.call(
|
|
103
|
+
event_name: event,
|
|
104
|
+
tenant_id: tenant_id,
|
|
105
|
+
target: self,
|
|
106
|
+
payload: serialize_for_approval
|
|
107
|
+
)
|
|
108
|
+
else
|
|
109
|
+
raise ArgumentError, "pass either event: or templates:"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Host override hook: return false to skip an automatic trigger. Receives
|
|
114
|
+
# the lifecycle (:create or :update), so you can gate per-event — e.g. only
|
|
115
|
+
# auto-route an update when a specific transition happened:
|
|
116
|
+
#
|
|
117
|
+
# def trigger_approval?(lifecycle)
|
|
118
|
+
# lifecycle == :update ? saved_change_to_status? : true
|
|
119
|
+
# end
|
|
120
|
+
def trigger_approval?(_lifecycle = nil)
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Preview what `event` *would* trigger for this record, without writing
|
|
125
|
+
# anything — handy for showing a user "this will go to Manager, then CFO"
|
|
126
|
+
# before they commit an action. Works against the in-memory record, so you
|
|
127
|
+
# can preview an unsaved change (`invoice.amount = 20_000; invoice.preview_...`).
|
|
128
|
+
# Returns an ApprovalEngine::ApprovalPlan.
|
|
129
|
+
def preview_approval(event:, tenant_id: approval_tenant_id)
|
|
130
|
+
ApprovalEngine::RuleEvaluator.preview(
|
|
131
|
+
event_name: event,
|
|
132
|
+
tenant_id: tenant_id,
|
|
133
|
+
target: self,
|
|
134
|
+
payload: serialize_for_approval
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Every approval that *would* match `event` for this record, in priority
|
|
139
|
+
# order — so you can let a user choose which to trigger rather than letting
|
|
140
|
+
# the engine auto-pick the top one. Returns an array of ApprovalPlan; writes
|
|
141
|
+
# nothing.
|
|
142
|
+
def approval_candidates(event:, tenant_id: approval_tenant_id)
|
|
143
|
+
ApprovalEngine::RuleEvaluator.candidates(
|
|
144
|
+
event_name: event,
|
|
145
|
+
tenant_id: tenant_id,
|
|
146
|
+
target: self,
|
|
147
|
+
payload: serialize_for_approval
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A read-only view of everything this record has gone through — approvals,
|
|
152
|
+
# the step tree, and a chronological timeline of actions + comments. The
|
|
153
|
+
# gem assembles it; you decide who may see it and how to render it.
|
|
154
|
+
def approval_history
|
|
155
|
+
ApprovalEngine::History.for(self)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def latest_approval
|
|
159
|
+
approvals.order(created_at: :desc).first
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def approval_in_flight?
|
|
163
|
+
approvals.pending.exists?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def approval_status
|
|
167
|
+
latest_approval&.status
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def auto_trigger_approval?(lifecycle)
|
|
173
|
+
approval_trigger_events.include?(lifecycle) && trigger_approval?(lifecycle)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Tenant id derived from the configured `current_tenant_method`. Override in
|
|
177
|
+
# the host model if the tenant lives somewhere model-specific.
|
|
178
|
+
def approval_tenant_id
|
|
179
|
+
tenant = ApprovalEngine.current_tenant
|
|
180
|
+
tenant.respond_to?(:id) ? tenant.id : tenant
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
module ApprovalEngine
|
|
2
|
+
# Stamps a concrete, actionable ledger (Approval → Track → Steps) out of one
|
|
3
|
+
# or more abstract TrackTemplates.
|
|
4
|
+
#
|
|
5
|
+
# A single template builds a single-track approval. Several templates build a
|
|
6
|
+
# scatter-gather approval: one parallel track per template, all active at once,
|
|
7
|
+
# gathering per the approval's `approvals_required` (`:all` by default).
|
|
8
|
+
#
|
|
9
|
+
# Each template step is expanded into one Step per resolved actor — so "any
|
|
10
|
+
# one of five senior devs" becomes five sibling steps sharing one consensus
|
|
11
|
+
# policy. Only each track's first layer starts `pending`; later layers wait
|
|
12
|
+
# until the layer before them resolves.
|
|
13
|
+
class ApprovalBuilder
|
|
14
|
+
class BuilderError < ApprovalEngine::Error; end
|
|
15
|
+
|
|
16
|
+
# Build a single-track approval from one template. `event_name` is the event
|
|
17
|
+
# that triggered this run (nil when started manually) — recorded on the
|
|
18
|
+
# Approval for audit/display. `trigger_rule` is the rule that matched, when
|
|
19
|
+
# auto-routed, so the approval remembers its provenance.
|
|
20
|
+
def self.build!(template:, target:, event_name: nil, trigger_rule: nil)
|
|
21
|
+
new(templates: [ template ], target: target, event_name: event_name, trigger_rule: trigger_rule).build!
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Build a scatter-gather approval, one parallel track per template.
|
|
25
|
+
# `approvals_required` is the gather consensus (default `:all`).
|
|
26
|
+
def self.build_parallel!(templates:, target:, event_name: nil, approvals_required: "all")
|
|
27
|
+
raise BuilderError, "build_parallel! needs at least one template" if templates.blank?
|
|
28
|
+
|
|
29
|
+
new(templates: templates, target: target, event_name: event_name, approvals_required: approvals_required).build!
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(templates:, target:, event_name: nil, trigger_rule: nil, approvals_required: "all")
|
|
33
|
+
@templates = templates
|
|
34
|
+
@target = target
|
|
35
|
+
@event_name = event_name
|
|
36
|
+
@trigger_rule = trigger_rule
|
|
37
|
+
@approvals_required = approvals_required.to_s
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build!
|
|
41
|
+
guard_single_tenant!
|
|
42
|
+
guard_gather_consensus!
|
|
43
|
+
ActiveRecord::Base.transaction do
|
|
44
|
+
approval = build_approval
|
|
45
|
+
templates.each { |template| build_track!(approval, template) }
|
|
46
|
+
approval
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The fail-closed quarantine state. Built when a dynamic rule blows up, so
|
|
51
|
+
# ops can see exactly why a track never started instead of hitting a 500.
|
|
52
|
+
def self.build_quarantine_approval!(target:, tenant_id:, reason:)
|
|
53
|
+
Approval.create!(tenant_id: tenant_id, target: target, status: "quarantined").tap do |approval|
|
|
54
|
+
OutboxEvent.create!(
|
|
55
|
+
tenant_id: approval.tenant_id,
|
|
56
|
+
event_name: "approval.quarantined",
|
|
57
|
+
record: approval,
|
|
58
|
+
error_payload: reason
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
attr_reader :templates, :target, :event_name, :trigger_rule, :approvals_required
|
|
66
|
+
|
|
67
|
+
def build_approval
|
|
68
|
+
Approval.create!(
|
|
69
|
+
tenant_id: templates.first.tenant_id,
|
|
70
|
+
target: target,
|
|
71
|
+
status: "pending",
|
|
72
|
+
event_name: event_name,
|
|
73
|
+
trigger_rule: trigger_rule,
|
|
74
|
+
approvals_required: approvals_required
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_track!(approval, template)
|
|
79
|
+
track = Track.create!(
|
|
80
|
+
tenant_id: template.tenant_id,
|
|
81
|
+
approval: approval,
|
|
82
|
+
name: template.name,
|
|
83
|
+
status: "pending"
|
|
84
|
+
)
|
|
85
|
+
build_steps(track, template)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_steps(track, template)
|
|
89
|
+
first_layer = template.template_steps.minimum(:layer)
|
|
90
|
+
|
|
91
|
+
template.template_steps.ordered.each do |tpl_step|
|
|
92
|
+
actors = resolve_actors(tpl_step)
|
|
93
|
+
guard_consensus!(tpl_step, actors)
|
|
94
|
+
|
|
95
|
+
actors.each do |actor|
|
|
96
|
+
track.steps.create!(
|
|
97
|
+
tenant_id: template.tenant_id,
|
|
98
|
+
name: tpl_step.name,
|
|
99
|
+
layer: tpl_step.layer,
|
|
100
|
+
iteration: 1,
|
|
101
|
+
status: tpl_step.layer == first_layer ? "pending" : "waiting",
|
|
102
|
+
approvals_required: tpl_step.approvals_required,
|
|
103
|
+
timeout_after: tpl_step.timeout_after,
|
|
104
|
+
assigned_actor: actor
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def resolve_actors(tpl_step)
|
|
111
|
+
actors = Array(host_actor_resolver.resolve_approval_group(tpl_step.assigned_group, target)).compact
|
|
112
|
+
|
|
113
|
+
if actors.empty?
|
|
114
|
+
raise BuilderError, "No actors resolved for group '#{tpl_step.assigned_group}'. " \
|
|
115
|
+
"Check #{host_actor_resolver}.resolve_approval_group."
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
actors
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A layer needing more approvals than it has actors could never resolve —
|
|
122
|
+
# fail loudly at build time instead of silently stranding the approval in
|
|
123
|
+
# `pending` forever. (Only an absolute count can exceed the group; relative
|
|
124
|
+
# specs like :majority / "60%" are always satisfiable.)
|
|
125
|
+
def guard_consensus!(tpl_step, actors)
|
|
126
|
+
required = Consensus.new(tpl_step.approvals_required).required(actors.size)
|
|
127
|
+
return if required <= actors.size
|
|
128
|
+
|
|
129
|
+
raise BuilderError, "Step '#{tpl_step.name}' needs #{required} approval(s) but only " \
|
|
130
|
+
"#{actors.size} actor(s) resolved for group '#{tpl_step.assigned_group}' " \
|
|
131
|
+
"— it could never resolve."
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The approval and all its rows are stamped from one tenant; mixed-tenant
|
|
135
|
+
# templates would scatter a single ledger across tenants. Fail at the boundary.
|
|
136
|
+
def guard_single_tenant!
|
|
137
|
+
tenants = templates.map(&:tenant_id).uniq
|
|
138
|
+
return if tenants.size == 1
|
|
139
|
+
|
|
140
|
+
raise BuilderError, "all templates must belong to one tenant (got #{tenants.inspect})."
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The gather twin of guard_consensus!: a fixed count exceeding the track
|
|
144
|
+
# count could never resolve.
|
|
145
|
+
def guard_gather_consensus!
|
|
146
|
+
raise BuilderError, "approvals_required #{approvals_required.inspect} is not a valid consensus spec." unless Consensus.valid?(approvals_required)
|
|
147
|
+
|
|
148
|
+
required = Consensus.new(approvals_required).required(templates.size)
|
|
149
|
+
return if required <= templates.size
|
|
150
|
+
|
|
151
|
+
raise BuilderError, "approval needs #{required} track approval(s) but only #{templates.size} " \
|
|
152
|
+
"track(s) were given — it could never resolve."
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def host_actor_resolver
|
|
156
|
+
klass = actor_class
|
|
157
|
+
|
|
158
|
+
unless klass.respond_to?(:resolve_approval_group)
|
|
159
|
+
raise BuilderError, "#{klass} must define `self.resolve_approval_group(group_name, target)`."
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
klass
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def actor_class
|
|
166
|
+
ApprovalEngine.config.actor_class_constant
|
|
167
|
+
rescue NameError => e
|
|
168
|
+
raise BuilderError, "ApprovalEngine.config.actor_class is #{ApprovalEngine.config.actor_class.inspect}, " \
|
|
169
|
+
"which doesn't resolve to a loaded class (#{e.message})."
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module ApprovalEngine
|
|
2
|
+
# Implements the append-only iteration cycle. Rather than resetting an approved
|
|
3
|
+
# step back to pending (which would destroy the audit trail), requesting
|
|
4
|
+
# changes clones the track's current iteration into a fresh one, so every
|
|
5
|
+
# past attempt remains permanently on the ledger.
|
|
6
|
+
#
|
|
7
|
+
# Always invoked while the approval is locked, so it does not lock again.
|
|
8
|
+
class IterationBuilder
|
|
9
|
+
def self.build_next_iteration!(from_step)
|
|
10
|
+
new(from_step).build!
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(from_step)
|
|
14
|
+
@from_step = from_step
|
|
15
|
+
@track = from_step.track
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def build!
|
|
19
|
+
blueprint = track.steps.for_iteration(from_step.iteration).order(:layer, :created_at).to_a
|
|
20
|
+
first_layer = blueprint.map(&:layer).min
|
|
21
|
+
|
|
22
|
+
blueprint.each do |old_step|
|
|
23
|
+
track.steps.create!(
|
|
24
|
+
tenant_id: old_step.tenant_id,
|
|
25
|
+
name: old_step.name,
|
|
26
|
+
layer: old_step.layer,
|
|
27
|
+
iteration: next_iteration,
|
|
28
|
+
status: old_step.layer == first_layer ? "pending" : "waiting",
|
|
29
|
+
approvals_required: old_step.approvals_required,
|
|
30
|
+
timeout_after: old_step.timeout_after,
|
|
31
|
+
assigned_actor: old_step.assigned_actor
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
attr_reader :from_step, :track
|
|
39
|
+
|
|
40
|
+
def next_iteration
|
|
41
|
+
@next_iteration ||= from_step.iteration + 1
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|