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,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