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,119 @@
|
|
|
1
|
+
require "shiny_json_logic"
|
|
2
|
+
|
|
3
|
+
module ApprovalEngine
|
|
4
|
+
# Resolves which track (if any) a host event should spawn by evaluating
|
|
5
|
+
# tenant-scoped JSON Logic rules against a flat payload.
|
|
6
|
+
#
|
|
7
|
+
# The payload is produced by the host model's `exposes_for_approval` DSL, so
|
|
8
|
+
# the engine never reaches into the host's domain directly — it only sees the
|
|
9
|
+
# whitelisted attributes it was handed.
|
|
10
|
+
#
|
|
11
|
+
# ApprovalEngine::RuleEvaluator.call(
|
|
12
|
+
# event_name: "invoice.created",
|
|
13
|
+
# tenant_id: account.id,
|
|
14
|
+
# target: invoice,
|
|
15
|
+
# payload: invoice.serialize_for_approval
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# `call` returns the spawned Approval, the quarantine Approval on a rule
|
|
19
|
+
# failure, or nil when no rule matched. `preview` runs the identical matching
|
|
20
|
+
# logic but writes nothing — see ApprovalPlan.
|
|
21
|
+
class RuleEvaluator
|
|
22
|
+
class EvaluationError < ApprovalEngine::Error; end
|
|
23
|
+
|
|
24
|
+
def self.call(event_name:, tenant_id:, target:, payload:)
|
|
25
|
+
new(event_name: event_name, tenant_id: tenant_id, target: target, payload: payload).call
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Side-effect-free dry run: what *would* this event trigger? Writes nothing,
|
|
29
|
+
# never quarantines, never raises. Returns an ApprovalPlan.
|
|
30
|
+
def self.preview(event_name:, tenant_id:, target:, payload:)
|
|
31
|
+
new(event_name: event_name, tenant_id: tenant_id, target: target, payload: payload).preview
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Side-effect-free: *every* rule that matches, in priority order, so the host
|
|
35
|
+
# can let a user choose which to trigger instead of auto-picking the top one.
|
|
36
|
+
# Returns an array of ApprovalPlan. Broken rules are skipped, not surfaced.
|
|
37
|
+
def self.candidates(event_name:, tenant_id:, target:, payload:)
|
|
38
|
+
new(event_name: event_name, tenant_id: tenant_id, target: target, payload: payload).candidates
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(event_name:, tenant_id:, target:, payload:)
|
|
42
|
+
@event_name = event_name
|
|
43
|
+
@tenant_id = tenant_id
|
|
44
|
+
@target = target
|
|
45
|
+
@payload = payload
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def call
|
|
49
|
+
status, rule = find_match
|
|
50
|
+
case status
|
|
51
|
+
when :match
|
|
52
|
+
ApprovalBuilder.build!(template: rule.track_template, target: target, event_name: event_name, trigger_rule: rule)
|
|
53
|
+
when :error
|
|
54
|
+
raise EvaluationError, @failure_reason if ApprovalEngine.config.raise_on_rule_errors
|
|
55
|
+
|
|
56
|
+
quarantine(rule)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def preview
|
|
61
|
+
status, rule = find_match
|
|
62
|
+
ApprovalPlan.new(status: status, template: rule&.track_template, target: target, reason: @failure_reason)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def candidates
|
|
66
|
+
candidate_rules.filter_map do |rule|
|
|
67
|
+
ApprovalPlan.new(status: :match, template: rule.track_template, target: target) if evaluate(rule) == :match
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
attr_reader :event_name, :tenant_id, :target, :payload
|
|
74
|
+
|
|
75
|
+
# Walk rules highest-priority-first; stop at the first match or the first
|
|
76
|
+
# broken rule (fail closed). Returns [status, rule] where status is one of
|
|
77
|
+
# :match, :no_match, :error. Shared by `call` and `preview` so a preview can
|
|
78
|
+
# never disagree with the real run.
|
|
79
|
+
def find_match
|
|
80
|
+
candidate_rules.each do |rule|
|
|
81
|
+
case evaluate(rule)
|
|
82
|
+
when :match then return [ :match, rule ]
|
|
83
|
+
when :error then return [ :error, rule ]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
[ :no_match, nil ]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def candidate_rules
|
|
91
|
+
TriggerRule.active
|
|
92
|
+
.for_event(event_name)
|
|
93
|
+
.where(tenant_id: tenant_id)
|
|
94
|
+
.order(priority: :desc)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Evaluates one rule. A *missing* payload key is a clean non-match (JSON
|
|
98
|
+
# Logic returns false). A *malformed* rule raises, which we capture as :error
|
|
99
|
+
# — the caller (`call` vs `preview`) decides whether to quarantine, raise, or
|
|
100
|
+
# just report. This method itself never raises.
|
|
101
|
+
def evaluate(rule)
|
|
102
|
+
ShinyJsonLogic.apply(rule.condition, payload) ? :match : :no_match
|
|
103
|
+
rescue => e
|
|
104
|
+
@failure_reason = "Rule #{rule.id} evaluation failed: #{e.class}: #{e.message}"
|
|
105
|
+
:error
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def quarantine(_rule)
|
|
109
|
+
# Quarantine is fail-closed and async (host hears via on_quarantined); log
|
|
110
|
+
# too, so a systematic rule bug is visible without inspecting rows.
|
|
111
|
+
Rails.logger&.warn("[ApprovalEngine] quarantined #{target.class}##{target.id} (#{tenant_id}): #{@failure_reason}")
|
|
112
|
+
ApprovalBuilder.build_quarantine_approval!(
|
|
113
|
+
target: target,
|
|
114
|
+
tenant_id: tenant_id,
|
|
115
|
+
reason: @failure_reason
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<% content_for :title, "Approvals" %>
|
|
2
|
+
<h1>Approvals</h1>
|
|
3
|
+
|
|
4
|
+
<div class="ae-filters">
|
|
5
|
+
<%= link_to approvals_path, class: ("is-active" unless @status) do %>
|
|
6
|
+
All <span class="ae-count"><%= @counts.values.sum %></span>
|
|
7
|
+
<% end %>
|
|
8
|
+
<% ApprovalEngine::Approval::STATUSES.each do |status| %>
|
|
9
|
+
<%= link_to approvals_path(status: status), class: ("is-active" if @status == status) do %>
|
|
10
|
+
<%= status.tr("_", " ") %> <span class="ae-count"><%= @counts.fetch(status, 0) %></span>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<% if @approvals.empty? %>
|
|
16
|
+
<div class="ae-empty">No approvals<%= " with status #{@status}" if @status %>.</div>
|
|
17
|
+
<% else %>
|
|
18
|
+
<table>
|
|
19
|
+
<thead>
|
|
20
|
+
<tr><th>Target</th><th>Event</th><th>Status</th><th>Tracks</th><th>Created</th><th></th></tr>
|
|
21
|
+
</thead>
|
|
22
|
+
<tbody>
|
|
23
|
+
<% @approvals.each do |approval| %>
|
|
24
|
+
<tr>
|
|
25
|
+
<td class="ae-mono"><%= target_label(approval.target) %></td>
|
|
26
|
+
<td><%= approval.event_name || "—" %></td>
|
|
27
|
+
<td><%= status_badge(approval.status) %></td>
|
|
28
|
+
<td><%= @track_counts.fetch(approval.id, 0) %></td>
|
|
29
|
+
<td class="ae-meta"><%= approval.created_at.to_fs(:short) %></td>
|
|
30
|
+
<td><%= link_to "View", approval_path(approval) %></td>
|
|
31
|
+
</tr>
|
|
32
|
+
<% end %>
|
|
33
|
+
</tbody>
|
|
34
|
+
</table>
|
|
35
|
+
<% if @total > @approvals.size %>
|
|
36
|
+
<p class="ae-meta">Showing the most recent <%= @approvals.size %> of <%= @total %>.</p>
|
|
37
|
+
<% end %>
|
|
38
|
+
<% end %>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<% content_for :title, "Approval" %>
|
|
2
|
+
<%= link_to "← All approvals", approvals_path, class: "ae-back" %>
|
|
3
|
+
<h1>Approval <%= status_badge(@approval.status) %></h1>
|
|
4
|
+
|
|
5
|
+
<div class="ae-card">
|
|
6
|
+
<div><strong>Target:</strong> <span class="ae-mono"><%= target_label(@approval.target) %></span></div>
|
|
7
|
+
<div><strong>Event:</strong> <%= @approval.event_name || "—" %></div>
|
|
8
|
+
<div><strong>Tenant:</strong> <span class="ae-mono"><%= @approval.tenant_id %></span></div>
|
|
9
|
+
<div class="ae-meta">Created <%= @approval.created_at.to_fs(:short) %></div>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<% @approval.tracks.each do |track| %>
|
|
13
|
+
<h2><%= track.name %> <%= status_badge(track.status) %></h2>
|
|
14
|
+
<table>
|
|
15
|
+
<thead>
|
|
16
|
+
<tr><th>Iter</th><th>Layer</th><th>Step</th><th>Assigned</th><th>Consensus</th><th>Status</th><th>Time in step</th></tr>
|
|
17
|
+
</thead>
|
|
18
|
+
<tbody>
|
|
19
|
+
<% track.steps.sort_by { |s| [s.iteration, s.layer] }.each do |step| %>
|
|
20
|
+
<tr>
|
|
21
|
+
<td><%= step.iteration %></td>
|
|
22
|
+
<td><%= step.layer %></td>
|
|
23
|
+
<td><%= step.name || "—" %></td>
|
|
24
|
+
<td class="ae-mono"><%= actor_label(step.assigned_actor) %></td>
|
|
25
|
+
<td><%= step.approvals_required %></td>
|
|
26
|
+
<td><%= status_badge(step.status) %></td>
|
|
27
|
+
<td class="ae-meta"><%= step.activated_at ? distance_of_time_in_words(step.activated_at, step.decided_at || Time.current) : "—" %></td>
|
|
28
|
+
</tr>
|
|
29
|
+
<% end %>
|
|
30
|
+
</tbody>
|
|
31
|
+
</table>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<h2>Audit trail</h2>
|
|
35
|
+
<% logs = @approval.steps.flat_map(&:audit_logs).sort_by(&:created_at) %>
|
|
36
|
+
<% if logs.empty? %>
|
|
37
|
+
<div class="ae-empty">No audit events yet.</div>
|
|
38
|
+
<% else %>
|
|
39
|
+
<table>
|
|
40
|
+
<thead>
|
|
41
|
+
<tr><th>When</th><th>Event</th><th>Actual actor</th><th>Intended</th><th>Comment</th></tr>
|
|
42
|
+
</thead>
|
|
43
|
+
<tbody>
|
|
44
|
+
<% logs.each do |log| %>
|
|
45
|
+
<tr>
|
|
46
|
+
<td class="ae-meta"><%= log.created_at.to_fs(:short) %></td>
|
|
47
|
+
<td><%= status_badge(log.event) %></td>
|
|
48
|
+
<td class="ae-mono"><%= actor_label(log.actual_actor) %></td>
|
|
49
|
+
<td class="ae-mono"><%= log.by_proxy? ? actor_label(log.intended_actor) : "—" %></td>
|
|
50
|
+
<td><%= log.comment %></td>
|
|
51
|
+
</tr>
|
|
52
|
+
<% end %>
|
|
53
|
+
</tbody>
|
|
54
|
+
</table>
|
|
55
|
+
<% end %>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>ApprovalEngine — <%= content_for?(:title) ? yield(:title) : "Dashboard" %></title>
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<style>
|
|
9
|
+
:root { --bg:#0f172a; --panel:#fff; --ink:#1e293b; --muted:#64748b; --line:#e2e8f0; --accent:#4f46e5; }
|
|
10
|
+
* { box-sizing: border-box; }
|
|
11
|
+
body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; color:var(--ink); background:#f1f5f9; }
|
|
12
|
+
header.ae-top { background:var(--bg); color:#fff; padding:14px 24px; display:flex; align-items:center; gap:12px; }
|
|
13
|
+
header.ae-top a { color:#fff; text-decoration:none; font-weight:600; }
|
|
14
|
+
header.ae-top .ae-tag { font-size:11px; color:#94a3b8; border:1px solid #334155; padding:1px 8px; border-radius:999px; }
|
|
15
|
+
main { max-width:1100px; margin:24px auto; padding:0 24px; }
|
|
16
|
+
h1 { font-size:20px; margin:0 0 16px; }
|
|
17
|
+
h2 { font-size:15px; margin:24px 0 8px; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; }
|
|
18
|
+
a { color:var(--accent); }
|
|
19
|
+
.ae-filters { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px; }
|
|
20
|
+
.ae-filters a { text-decoration:none; background:#fff; border:1px solid var(--line); border-radius:999px; padding:4px 12px; color:var(--ink); }
|
|
21
|
+
.ae-filters a.is-active { background:var(--accent); color:#fff; border-color:var(--accent); }
|
|
22
|
+
.ae-filters .ae-count { color:var(--muted); font-variant-numeric:tabular-nums; }
|
|
23
|
+
.ae-filters a.is-active .ae-count { color:#e0e7ff; }
|
|
24
|
+
table { width:100%; border-collapse:collapse; background:var(--panel); border:1px solid var(--line); border-radius:10px; overflow:hidden; }
|
|
25
|
+
th, td { text-align:left; padding:10px 14px; border-bottom:1px solid var(--line); vertical-align:top; }
|
|
26
|
+
th { background:#f8fafc; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.03em; }
|
|
27
|
+
tr:last-child td { border-bottom:0; }
|
|
28
|
+
.ae-badge { display:inline-block; font-size:11px; font-weight:600; padding:2px 9px; border-radius:999px; text-transform:capitalize; }
|
|
29
|
+
.is-pending{background:#fef9c3;color:#854d0e;} .is-waiting{background:#e2e8f0;color:#475569;}
|
|
30
|
+
.is-approved{background:#dcfce7;color:#166534;} .is-rejected{background:#fee2e2;color:#991b1b;}
|
|
31
|
+
.is-changes-requested{background:#ffedd5;color:#9a3412;} .is-cancelled{background:#f1f5f9;color:#94a3b8;}
|
|
32
|
+
.is-quarantined{background:#fae8ff;color:#86198f;}
|
|
33
|
+
.ae-empty { background:#fff; border:1px dashed var(--line); border-radius:10px; padding:40px; text-align:center; color:var(--muted); }
|
|
34
|
+
.ae-meta { color:var(--muted); font-size:13px; }
|
|
35
|
+
.ae-mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; }
|
|
36
|
+
.ae-card { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px 20px; margin-bottom:16px; }
|
|
37
|
+
.ae-back { display:inline-block; margin-bottom:12px; }
|
|
38
|
+
</style>
|
|
39
|
+
</head>
|
|
40
|
+
<body>
|
|
41
|
+
<header class="ae-top">
|
|
42
|
+
<%= link_to "ApprovalEngine", root_path %>
|
|
43
|
+
<span class="ae-tag">ops dashboard</span>
|
|
44
|
+
</header>
|
|
45
|
+
<main>
|
|
46
|
+
<%= yield %>
|
|
47
|
+
</main>
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
ApprovalEngine::Engine.routes.draw do
|
|
2
|
+
# Read-only ops dashboard. Mount behind your own auth, e.g.:
|
|
3
|
+
# authenticate :admin_user, ->(u) { u.super_admin? } do
|
|
4
|
+
# mount ApprovalEngine::Engine => "/approval_engine"
|
|
5
|
+
# end
|
|
6
|
+
root to: "approvals#index"
|
|
7
|
+
resources :approvals, only: %i[index show]
|
|
8
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
class CreateApprovalEngineCoreTables < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
# The orchestrator. One per host record + event. Holds the parallel
|
|
4
|
+
# track tracks and the overall outcome.
|
|
5
|
+
create_table :approval_engine_approvals, id: :uuid do |t|
|
|
6
|
+
t.string :tenant_id, null: false, index: true
|
|
7
|
+
t.references :target, polymorphic: true, null: false, index: true
|
|
8
|
+
t.string :status, null: false, default: "pending", index: true
|
|
9
|
+
t.string :event_name
|
|
10
|
+
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# A single track within an approval (e.g. "Finance", "Legal"). Approvals with
|
|
15
|
+
# more than one track model scatter-gather parallelism.
|
|
16
|
+
create_table :approval_engine_tracks, id: :uuid do |t|
|
|
17
|
+
t.string :tenant_id, null: false
|
|
18
|
+
t.references :approval_engine_approval, null: false, foreign_key: true, type: :uuid
|
|
19
|
+
t.string :name, null: false
|
|
20
|
+
t.string :status, null: false, default: "pending"
|
|
21
|
+
|
|
22
|
+
t.timestamps
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The immutable ledger row. Steps are never updated backwards: a rework
|
|
26
|
+
# rejection appends a fresh iteration rather than resetting history.
|
|
27
|
+
create_table :approval_engine_steps, id: :uuid do |t|
|
|
28
|
+
t.string :tenant_id, null: false
|
|
29
|
+
t.references :approval_engine_track, null: false, foreign_key: true, type: :uuid
|
|
30
|
+
t.string :name
|
|
31
|
+
t.integer :layer, null: false, default: 1
|
|
32
|
+
t.integer :iteration, null: false, default: 1
|
|
33
|
+
t.string :status, null: false, default: "pending", index: true
|
|
34
|
+
# How many approvals this step's layer needs: any | all | majority | "60%" | "2".
|
|
35
|
+
t.string :approvals_required, null: false, default: "any"
|
|
36
|
+
t.references :assigned_actor, polymorphic: true, null: false
|
|
37
|
+
|
|
38
|
+
# Cycle-time facts for latency/bottleneck reporting: when the step became
|
|
39
|
+
# actionable (pending), and when a human resolved it. Both stay null while
|
|
40
|
+
# waiting; decided_at also stays null for cancelled/expired steps (never decided).
|
|
41
|
+
t.datetime :activated_at
|
|
42
|
+
t.datetime :decided_at
|
|
43
|
+
|
|
44
|
+
# Timeout — three distinct facts:
|
|
45
|
+
# timeout_after : the SLA window in seconds, copied from the template like
|
|
46
|
+
# the step's other facts (the ledger stays self-contained).
|
|
47
|
+
# timeout_at : the deadline itself, fixed when the step is activated.
|
|
48
|
+
# Stored (not recomputed) and indexed so the sweep stays a
|
|
49
|
+
# plain `timeout_at <= now`, not interval arithmetic.
|
|
50
|
+
# timed_out_at : when the timeout fired — so it fires exactly once.
|
|
51
|
+
t.integer :timeout_after
|
|
52
|
+
t.datetime :timeout_at
|
|
53
|
+
t.datetime :timed_out_at
|
|
54
|
+
|
|
55
|
+
t.timestamps
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Append-only audit trail. Records the intended actor (who was assigned) vs.
|
|
59
|
+
# the actual actor (who acted, possibly a delegate) for strict compliance.
|
|
60
|
+
create_table :approval_engine_audit_logs, id: :uuid do |t|
|
|
61
|
+
t.string :tenant_id, null: false
|
|
62
|
+
t.references :approval_engine_step, null: false, foreign_key: true, type: :uuid
|
|
63
|
+
t.string :event, null: false
|
|
64
|
+
t.references :intended_actor, polymorphic: true, null: true
|
|
65
|
+
# Nullable: system events (e.g. a step timing out) have no human actor —
|
|
66
|
+
# the ledger records that honestly rather than fabricating one.
|
|
67
|
+
t.references :actual_actor, polymorphic: true, null: true
|
|
68
|
+
t.text :comment
|
|
69
|
+
|
|
70
|
+
t.timestamps
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Fast lookup for "my pending approvals" dashboards.
|
|
74
|
+
add_index :approval_engine_steps,
|
|
75
|
+
%i[tenant_id assigned_actor_type assigned_actor_id status],
|
|
76
|
+
name: "idx_approval_engine_pending_tasks"
|
|
77
|
+
|
|
78
|
+
# Enforce the enumerated values at the database level, not just in Ruby —
|
|
79
|
+
# the ledger is the source of truth, so a raw write can't corrupt it.
|
|
80
|
+
add_check_constraint :approval_engine_approvals,
|
|
81
|
+
"status IN ('pending','approved','rejected','quarantined','cancelled')",
|
|
82
|
+
name: "chk_approval_engine_approval_status"
|
|
83
|
+
add_check_constraint :approval_engine_tracks,
|
|
84
|
+
"status IN ('pending','approved','rejected','cancelled')",
|
|
85
|
+
name: "chk_approval_engine_track_status"
|
|
86
|
+
add_check_constraint :approval_engine_steps,
|
|
87
|
+
"status IN ('waiting','pending','approved','rejected','changes_requested','expired','cancelled')",
|
|
88
|
+
name: "chk_approval_engine_step_status"
|
|
89
|
+
add_check_constraint :approval_engine_steps,
|
|
90
|
+
"approvals_required ~ '^([1-9][0-9]*%?|any|all|majority)$'",
|
|
91
|
+
name: "chk_approval_engine_step_approvals_required"
|
|
92
|
+
|
|
93
|
+
# Hot-path indexes the read/write queries actually use.
|
|
94
|
+
add_index :approval_engine_tracks, %i[approval_engine_approval_id status],
|
|
95
|
+
name: "idx_ae_tracks_approval_status"
|
|
96
|
+
add_index :approval_engine_steps, %i[approval_engine_track_id iteration layer status],
|
|
97
|
+
name: "idx_ae_steps_layer_consensus"
|
|
98
|
+
add_index :approval_engine_approvals, %i[target_type target_id created_at],
|
|
99
|
+
name: "idx_ae_approvals_target_recency"
|
|
100
|
+
add_index :approval_engine_audit_logs, %i[tenant_id created_at]
|
|
101
|
+
|
|
102
|
+
# The timeout sweep only cares about steps with a live, unfired deadline.
|
|
103
|
+
add_index :approval_engine_steps, :timeout_at,
|
|
104
|
+
where: "timeout_at IS NOT NULL AND timed_out_at IS NULL",
|
|
105
|
+
name: "idx_ae_steps_overdue"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
class CreateApprovalEngineInfrastructureTables < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :approval_engine_outbox_events, id: :uuid do |t|
|
|
4
|
+
t.string :tenant_id, null: false
|
|
5
|
+
t.string :event_name, null: false
|
|
6
|
+
t.references :record, polymorphic: true, null: false, type: :uuid
|
|
7
|
+
t.boolean :processed, null: false, default: false, index: true
|
|
8
|
+
t.datetime :processed_at
|
|
9
|
+
t.text :error_payload
|
|
10
|
+
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
create_table :approval_engine_delegations, id: :uuid do |t|
|
|
15
|
+
t.string :tenant_id, null: false
|
|
16
|
+
t.references :delegator, polymorphic: true, null: false
|
|
17
|
+
t.references :delegatee, polymorphic: true, null: false
|
|
18
|
+
t.datetime :starts_at, null: false
|
|
19
|
+
t.datetime :ends_at, null: false
|
|
20
|
+
t.boolean :active, null: false, default: true
|
|
21
|
+
|
|
22
|
+
t.timestamps
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Serves the inbox's `in_effect.where(delegatee:)` time-window lookup.
|
|
26
|
+
add_index :approval_engine_delegations,
|
|
27
|
+
%i[delegatee_type delegatee_id active starts_at ends_at],
|
|
28
|
+
name: "idx_ae_delegations_lookup"
|
|
29
|
+
|
|
30
|
+
# Partial index keeps the outbox `drain!` backlog scan tiny.
|
|
31
|
+
add_index :approval_engine_outbox_events, :created_at,
|
|
32
|
+
where: "processed = false",
|
|
33
|
+
name: "idx_ae_outbox_unprocessed"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
class CreateApprovalEngineBlueprintTables < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
# The reusable blueprint an approval is stamped from. Event-agnostic: which
|
|
4
|
+
# event triggers it is a routing concern owned by TriggerRule, not the template.
|
|
5
|
+
create_table :approval_engine_track_templates, id: :uuid do |t|
|
|
6
|
+
t.string :tenant_id, null: false, index: true
|
|
7
|
+
t.string :name, null: false
|
|
8
|
+
t.string :status, null: false, default: "draft"
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The ordered layers of a template (custom index name keeps it under 63 chars).
|
|
14
|
+
create_table :approval_engine_template_steps, id: :uuid do |t|
|
|
15
|
+
t.references :approval_engine_track_template,
|
|
16
|
+
null: false,
|
|
17
|
+
foreign_key: true,
|
|
18
|
+
type: :uuid,
|
|
19
|
+
index: { name: "idx_ae_tpl_steps_on_tpl_id" }
|
|
20
|
+
t.string :name, null: false
|
|
21
|
+
t.integer :layer, null: false, default: 1
|
|
22
|
+
t.string :approvals_required, null: false, default: "any"
|
|
23
|
+
t.string :assigned_group, null: false
|
|
24
|
+
# Optional SLA: seconds the step gets once it becomes actionable. nil = no
|
|
25
|
+
# deadline. Stamped onto each concrete Step; the host sweeps for breaches.
|
|
26
|
+
t.integer :timeout_after
|
|
27
|
+
|
|
28
|
+
t.timestamps
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The JSON Logic routing rules.
|
|
32
|
+
create_table :approval_engine_trigger_rules, id: :uuid do |t|
|
|
33
|
+
t.string :tenant_id, null: false, index: true
|
|
34
|
+
t.string :event_name, null: false
|
|
35
|
+
t.jsonb :condition, null: false, default: {}
|
|
36
|
+
t.integer :priority, null: false, default: 0
|
|
37
|
+
t.references :approval_engine_track_template,
|
|
38
|
+
null: false,
|
|
39
|
+
foreign_key: true,
|
|
40
|
+
type: :uuid,
|
|
41
|
+
index: { name: "idx_ae_trigger_rules_on_tpl_id" }
|
|
42
|
+
t.boolean :active, null: false, default: true
|
|
43
|
+
|
|
44
|
+
t.timestamps
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
add_index :approval_engine_trigger_rules, :condition, using: :gin
|
|
48
|
+
|
|
49
|
+
add_check_constraint :approval_engine_track_templates,
|
|
50
|
+
"status IN ('draft','active','archived')",
|
|
51
|
+
name: "chk_approval_engine_template_status"
|
|
52
|
+
add_check_constraint :approval_engine_template_steps,
|
|
53
|
+
"approvals_required ~ '^([1-9][0-9]*%?|any|all|majority)$'",
|
|
54
|
+
name: "chk_approval_engine_template_step_approvals_required"
|
|
55
|
+
|
|
56
|
+
# Rule resolution runs on every triggering event — make it one index scan.
|
|
57
|
+
add_index :approval_engine_trigger_rules, %i[tenant_id event_name active priority],
|
|
58
|
+
name: "idx_ae_trigger_rules_resolution"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class AddTriggerRuleToApprovals < ActiveRecord::Migration[7.0]
|
|
2
|
+
# Provenance: which TriggerRule auto-routed this approval. Nullable — an
|
|
3
|
+
# approval started manually via run_approval!(templates:) has no matching
|
|
4
|
+
# rule. on_delete: :nullify so retiring a rule never blocks or rewrites the
|
|
5
|
+
# historical approvals it once spawned (the ledger stays append-only).
|
|
6
|
+
def change
|
|
7
|
+
add_reference :approval_engine_approvals,
|
|
8
|
+
:approval_engine_trigger_rule,
|
|
9
|
+
type: :uuid,
|
|
10
|
+
null: true,
|
|
11
|
+
foreign_key: { to_table: :approval_engine_trigger_rules, on_delete: :nullify },
|
|
12
|
+
index: { name: "idx_ae_approvals_on_trigger_rule" }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class AddApprovalsRequiredToApprovals < ActiveRecord::Migration[7.0]
|
|
2
|
+
# The gather consensus: how many of an approval's parallel tracks must approve.
|
|
3
|
+
# Defaults to "all" so existing approvals keep their unanimity behaviour.
|
|
4
|
+
def change
|
|
5
|
+
add_column :approval_engine_approvals, :approvals_required, :string, null: false, default: "all"
|
|
6
|
+
|
|
7
|
+
add_check_constraint :approval_engine_approvals,
|
|
8
|
+
"approvals_required ~ '^([1-9][0-9]*%?|any|all|majority)$'",
|
|
9
|
+
name: "chk_approval_engine_approval_approvals_required"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class AddDeliveryTrackingToOutboxEvents < ActiveRecord::Migration[7.0]
|
|
2
|
+
# `error_payload` carries the *semantic* reason (why an approval was rejected,
|
|
3
|
+
# quarantined, or cancelled) that the host callback consumes. Delivery failures
|
|
4
|
+
# now record their backtrace in `delivery_error` so a retry can't clobber that
|
|
5
|
+
# reason. `failed_at` is the dead-letter mark: a row whose retries are exhausted,
|
|
6
|
+
# so `drain!` stops resurrecting it forever.
|
|
7
|
+
def change
|
|
8
|
+
add_column :approval_engine_outbox_events, :delivery_error, :text
|
|
9
|
+
add_column :approval_engine_outbox_events, :failed_at, :datetime
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module ApprovalEngine
|
|
2
|
+
# The anti-corruption layer. Collects the explicitly whitelisted attributes a
|
|
3
|
+
# host model is willing to expose to the dynamic rules engine, and produces a
|
|
4
|
+
# flat, string-keyed payload from them.
|
|
5
|
+
#
|
|
6
|
+
# The rules engine only ever sees what was declared here — never the raw
|
|
7
|
+
# model — so a SaaS admin's JSON Logic can never reach into unsafe internals.
|
|
8
|
+
#
|
|
9
|
+
# exposes_for_approval do
|
|
10
|
+
# attribute :amount, type: :decimal
|
|
11
|
+
# attribute :department, type: :string, source: ->(invoice) { invoice.department.name }
|
|
12
|
+
# attribute :is_high_risk, type: :boolean, source: :requires_manual_audit?
|
|
13
|
+
# end
|
|
14
|
+
class ApprovalExposure
|
|
15
|
+
# A single whitelisted attribute. `source` decides how the value is read:
|
|
16
|
+
# nil -> read the attribute/method named `name`
|
|
17
|
+
# Symbol -> call that method on the record
|
|
18
|
+
# Proc -> call it with the record
|
|
19
|
+
Attribute = Struct.new(:name, :type, :source, keyword_init: true) do
|
|
20
|
+
def value_for(record)
|
|
21
|
+
case source
|
|
22
|
+
when nil then record.public_send(name)
|
|
23
|
+
when Symbol then record.public_send(source)
|
|
24
|
+
when Proc then source.call(record)
|
|
25
|
+
else source
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_reader :attributes
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
@attributes = {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Keep dup'd exposures independent so per-class definitions never leak into
|
|
37
|
+
# one another (class_attribute inheritance relies on this).
|
|
38
|
+
def initialize_dup(other)
|
|
39
|
+
super
|
|
40
|
+
@attributes = other.attributes.dup
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def attribute(name, type: :string, source: nil)
|
|
44
|
+
@attributes[name.to_s] = Attribute.new(name: name, type: type, source: source)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The flat payload handed to the JSON Logic evaluator.
|
|
48
|
+
def serialize(record)
|
|
49
|
+
@attributes.transform_values { |attr| coerce(attr.value_for(record), attr.type) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def coerce(value, type)
|
|
55
|
+
return nil if value.nil?
|
|
56
|
+
|
|
57
|
+
case type
|
|
58
|
+
when :integer then value.to_i
|
|
59
|
+
when :decimal, :float then value.to_f
|
|
60
|
+
when :boolean then ActiveModel::Type::Boolean.new.cast(value)
|
|
61
|
+
when :string then value.to_s
|
|
62
|
+
else value
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module ApprovalEngine
|
|
2
|
+
# Host-tunable configuration. Every knob here is a *seam*: the engine ships a
|
|
3
|
+
# sensible default and lets the host application override behaviour without
|
|
4
|
+
# the engine having to know anything about the host's domain.
|
|
5
|
+
#
|
|
6
|
+
# ApprovalEngine.configure do |config|
|
|
7
|
+
# config.outbox_queue = :high_priority
|
|
8
|
+
# config.actor_class = "User"
|
|
9
|
+
# config.current_tenant_method = -> { Current.account }
|
|
10
|
+
# end
|
|
11
|
+
class Configuration
|
|
12
|
+
# A callable (lambda/proc) that returns the current tenant, e.g.
|
|
13
|
+
# `-> { Current.account }`. The engine only ever reads `#id` off the result.
|
|
14
|
+
attr_accessor :current_tenant_method
|
|
15
|
+
|
|
16
|
+
# The ActiveJob queue the transactional outbox is processed on.
|
|
17
|
+
attr_accessor :outbox_queue
|
|
18
|
+
|
|
19
|
+
# Name of the host's actor class (the thing that approves). It must respond
|
|
20
|
+
# to `resolve_approval_group(group_name, target)`. Kept as a String so the
|
|
21
|
+
# engine never holds a reference to an un-reloadable constant in development.
|
|
22
|
+
attr_accessor :actor_class
|
|
23
|
+
|
|
24
|
+
# When a dynamic rule blows up (e.g. a typo'd payload key), the engine fails
|
|
25
|
+
# *closed* by quarantining the approval rather than raising into your app.
|
|
26
|
+
# Flip this to `true` in development/test to surface the error loudly instead.
|
|
27
|
+
attr_accessor :raise_on_rule_errors
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@outbox_queue = :default
|
|
31
|
+
@current_tenant_method = nil
|
|
32
|
+
@actor_class = "User"
|
|
33
|
+
@raise_on_rule_errors = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The host's actor class, resolved lazily so reloading works in development.
|
|
37
|
+
def actor_class_constant
|
|
38
|
+
actor_class.to_s.constantize
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resolves the current tenant via the configured callable. Returns nil when
|
|
42
|
+
# the host has not configured tenancy (single-tenant apps are welcome too).
|
|
43
|
+
def current_tenant
|
|
44
|
+
current_tenant_method&.call
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
def config
|
|
50
|
+
@config ||= Configuration.new
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def configure
|
|
54
|
+
yield(config)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Resets configuration to defaults. Primarily a test-suite affordance.
|
|
58
|
+
def reset_configuration!
|
|
59
|
+
@config = Configuration.new
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Convenience reader used across the engine to scope queries by tenant.
|
|
63
|
+
def current_tenant
|
|
64
|
+
config.current_tenant
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module ApprovalEngine
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace ApprovalEngine
|
|
4
|
+
|
|
5
|
+
# Make `has_approvals` available on every ActiveRecord model.
|
|
6
|
+
initializer "approval_engine.model_extensions" do
|
|
7
|
+
ActiveSupport.on_load(:active_record) do
|
|
8
|
+
extend ApprovalEngine::ModelExtensions
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Keep the engine's own tests/factories from bleeding into a host app.
|
|
13
|
+
config.generators do |g|
|
|
14
|
+
g.test_framework :test_unit, fixture: false
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|