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