decision_agent 0.1.2 → 0.1.3

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +212 -35
  3. data/bin/decision_agent +3 -8
  4. data/lib/decision_agent/agent.rb +19 -26
  5. data/lib/decision_agent/audit/null_adapter.rb +1 -2
  6. data/lib/decision_agent/decision.rb +3 -1
  7. data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
  8. data/lib/decision_agent/dsl/rule_parser.rb +4 -6
  9. data/lib/decision_agent/dsl/schema_validator.rb +27 -31
  10. data/lib/decision_agent/errors.rb +11 -8
  11. data/lib/decision_agent/evaluation.rb +3 -1
  12. data/lib/decision_agent/evaluation_validator.rb +78 -0
  13. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
  14. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
  15. data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
  16. data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
  17. data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
  18. data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
  19. data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
  20. data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
  21. data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
  22. data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
  23. data/lib/decision_agent/replay/replay.rb +12 -22
  24. data/lib/decision_agent/scoring/base.rb +1 -1
  25. data/lib/decision_agent/scoring/consensus.rb +5 -5
  26. data/lib/decision_agent/scoring/weighted_average.rb +1 -1
  27. data/lib/decision_agent/version.rb +1 -1
  28. data/lib/decision_agent/versioning/activerecord_adapter.rb +69 -33
  29. data/lib/decision_agent/versioning/adapter.rb +1 -3
  30. data/lib/decision_agent/versioning/file_storage_adapter.rb +143 -35
  31. data/lib/decision_agent/versioning/version_manager.rb +4 -12
  32. data/lib/decision_agent/web/public/index.html +1 -1
  33. data/lib/decision_agent/web/server.rb +19 -24
  34. data/lib/decision_agent.rb +7 -0
  35. data/lib/generators/decision_agent/install/install_generator.rb +5 -5
  36. data/lib/generators/decision_agent/install/templates/migration.rb +17 -6
  37. data/lib/generators/decision_agent/install/templates/rule.rb +3 -3
  38. data/lib/generators/decision_agent/install/templates/rule_version.rb +13 -7
  39. data/spec/activerecord_thread_safety_spec.rb +553 -0
  40. data/spec/agent_spec.rb +13 -13
  41. data/spec/api_contract_spec.rb +16 -16
  42. data/spec/audit_adapters_spec.rb +3 -3
  43. data/spec/comprehensive_edge_cases_spec.rb +86 -86
  44. data/spec/dsl_validation_spec.rb +83 -83
  45. data/spec/edge_cases_spec.rb +23 -23
  46. data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
  47. data/spec/examples.txt +548 -0
  48. data/spec/issue_verification_spec.rb +685 -0
  49. data/spec/json_rule_evaluator_spec.rb +15 -15
  50. data/spec/monitoring/alert_manager_spec.rb +378 -0
  51. data/spec/monitoring/metrics_collector_spec.rb +281 -0
  52. data/spec/monitoring/monitored_agent_spec.rb +222 -0
  53. data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
  54. data/spec/replay_edge_cases_spec.rb +58 -58
  55. data/spec/replay_spec.rb +11 -11
  56. data/spec/rfc8785_canonicalization_spec.rb +215 -0
  57. data/spec/scoring_spec.rb +1 -1
  58. data/spec/spec_helper.rb +9 -0
  59. data/spec/thread_safety_spec.rb +482 -0
  60. data/spec/thread_safety_spec.rb.broken +878 -0
  61. data/spec/versioning_spec.rb +141 -37
  62. data/spec/web_ui_rack_spec.rb +135 -0
  63. metadata +69 -6
@@ -4,21 +4,41 @@ require "fileutils"
4
4
 
5
5
  module DecisionAgent
6
6
  module Versioning
7
+ # Status validation shared by adapters
8
+ module StatusValidator
9
+ VALID_STATUSES = %w[draft active archived].freeze
10
+
11
+ def validate_status!(status)
12
+ return if VALID_STATUSES.include?(status)
13
+
14
+ raise DecisionAgent::ValidationError,
15
+ "Invalid status '#{status}'. Must be one of: #{VALID_STATUSES.join(', ')}"
16
+ end
17
+ end
18
+
7
19
  # File-based version storage adapter for non-Rails applications
8
20
  # Stores versions as JSON files in a directory structure
9
21
  class FileStorageAdapter < Adapter
22
+ include StatusValidator
23
+
10
24
  attr_reader :storage_path
11
25
 
12
26
  # Initialize with a storage directory
13
27
  # @param storage_path [String] Path to store version files (default: ./versions)
14
28
  def initialize(storage_path: "./versions")
15
29
  @storage_path = storage_path
16
- @mutex = Mutex.new
30
+ # Per-rule mutex for better concurrency - allows different rules to be processed in parallel
31
+ @rule_mutexes = Hash.new { |h, k| h[k] = Mutex.new }
32
+ @rule_mutexes_lock = Mutex.new # Protects the hash itself
33
+ # Index cache: version_id => rule_id mapping for O(1) lookups
34
+ @version_index = {}
35
+ @version_index_lock = Mutex.new
17
36
  FileUtils.mkdir_p(@storage_path)
37
+ load_version_index
18
38
  end
19
39
 
20
40
  def create_version(rule_id:, content:, metadata: {})
21
- @mutex.synchronize do
41
+ with_rule_lock(rule_id) do
22
42
  create_version_unsafe(rule_id: rule_id, content: content, metadata: metadata)
23
43
  end
24
44
  end
@@ -27,14 +47,16 @@ module DecisionAgent
27
47
 
28
48
  def create_version_unsafe(rule_id:, content:, metadata: {})
29
49
  # Get the next version number
30
- versions = list_versions(rule_id: rule_id)
50
+ versions = list_versions_unsafe(rule_id: rule_id)
31
51
  next_version_number = versions.empty? ? 1 : versions.first[:version_number] + 1
32
52
 
53
+ # Validate status if provided
54
+ status = metadata[:status] || "active"
55
+ validate_status!(status)
56
+
33
57
  # Deactivate previous active versions
34
58
  versions.each do |v|
35
- if v[:status] == "active"
36
- update_version_status(v[:id], "archived")
37
- end
59
+ update_version_status_unsafe(v[:id], "archived", rule_id) if v[:status] == "active"
38
60
  end
39
61
 
40
62
  # Create version data
@@ -47,7 +69,7 @@ module DecisionAgent
47
69
  created_by: metadata[:created_by] || "system",
48
70
  created_at: Time.now.utc.iso8601,
49
71
  changelog: metadata[:changelog] || "Version #{next_version_number}",
50
- status: metadata[:status] || "active"
72
+ status: status
51
73
  }
52
74
 
53
75
  # Write to file
@@ -59,43 +81,53 @@ module DecisionAgent
59
81
  public
60
82
 
61
83
  def list_versions(rule_id:, limit: nil)
62
- versions = []
63
- rule_dir = File.join(@storage_path, sanitize_filename(rule_id))
64
-
65
- return versions unless Dir.exist?(rule_dir)
66
-
67
- Dir.glob(File.join(rule_dir, "*.json")).each do |file|
68
- versions << JSON.parse(File.read(file), symbolize_names: true)
84
+ with_rule_lock(rule_id) do
85
+ list_versions_unsafe(rule_id: rule_id, limit: limit)
69
86
  end
70
-
71
- versions.sort_by! { |v| -v[:version_number] }
72
- limit ? versions.take(limit) : versions
73
87
  end
74
88
 
75
89
  def get_version(version_id:)
76
- all_versions.find { |v| v[:id] == version_id }
90
+ # Use index to find rule_id quickly - O(1) instead of O(n)
91
+ rule_id = get_rule_id_from_index(version_id)
92
+ return nil unless rule_id
93
+
94
+ # Now lock on the specific rule
95
+ with_rule_lock(rule_id) do
96
+ # Read only this rule's versions
97
+ versions = list_versions_unsafe(rule_id: rule_id)
98
+ versions.find { |v| v[:id] == version_id }
99
+ end
77
100
  end
78
101
 
79
102
  def get_version_by_number(rule_id:, version_number:)
80
- versions = list_versions(rule_id: rule_id)
81
- versions.find { |v| v[:version_number] == version_number }
103
+ with_rule_lock(rule_id) do
104
+ versions = list_versions_unsafe(rule_id: rule_id)
105
+ versions.find { |v| v[:version_number] == version_number }
106
+ end
82
107
  end
83
108
 
84
109
  def get_active_version(rule_id:)
85
- versions = list_versions(rule_id: rule_id)
86
- versions.find { |v| v[:status] == "active" }
110
+ with_rule_lock(rule_id) do
111
+ versions = list_versions_unsafe(rule_id: rule_id)
112
+ versions.find { |v| v[:status] == "active" }
113
+ end
87
114
  end
88
115
 
89
116
  def activate_version(version_id:)
90
- @mutex.synchronize do
91
- version = get_version(version_id: version_id)
117
+ # Use index to find rule_id quickly - O(1) instead of O(n)
118
+ rule_id = get_rule_id_from_index(version_id)
119
+ raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless rule_id
120
+
121
+ # Now lock on the specific rule
122
+ with_rule_lock(rule_id) do
123
+ # Read only this rule's versions
124
+ versions = list_versions_unsafe(rule_id: rule_id)
125
+ version = versions.find { |v| v[:id] == version_id }
92
126
  raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless version
93
127
 
94
128
  # Deactivate all other versions for this rule
95
- list_versions(rule_id: version[:rule_id]).each do |v|
96
- if v[:id] != version_id && v[:status] == "active"
97
- update_version_status(v[:id], "archived")
98
- end
129
+ versions.each do |v|
130
+ update_version_status_unsafe(v[:id], "archived", rule_id) if v[:id] != version_id && v[:status] == "active"
99
131
  end
100
132
 
101
133
  # Activate this version
@@ -107,8 +139,15 @@ module DecisionAgent
107
139
  end
108
140
 
109
141
  def delete_version(version_id:)
110
- @mutex.synchronize do
111
- version = get_version(version_id: version_id)
142
+ # Use index to find rule_id quickly - O(1) instead of O(n)
143
+ rule_id = get_rule_id_from_index(version_id)
144
+ raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless rule_id
145
+
146
+ # Now lock on the specific rule
147
+ with_rule_lock(rule_id) do
148
+ # Read only this rule's versions
149
+ versions = list_versions_unsafe(rule_id: rule_id)
150
+ version = versions.find { |v| v[:id] == version_id }
112
151
  raise DecisionAgent::NotFoundError, "Version not found: #{version_id}" unless version
113
152
 
114
153
  # Prevent deletion of active versions
@@ -117,12 +156,14 @@ module DecisionAgent
117
156
  end
118
157
 
119
158
  # Delete the file
120
- rule_dir = File.join(@storage_path, sanitize_filename(version[:rule_id]))
159
+ rule_dir = File.join(@storage_path, sanitize_filename(rule_id))
121
160
  filename = "#{version[:version_number]}.json"
122
161
  filepath = File.join(rule_dir, filename)
123
162
 
124
163
  if File.exist?(filepath)
125
164
  File.delete(filepath)
165
+ # Remove from index
166
+ remove_from_index(version_id)
126
167
  true
127
168
  else
128
169
  false
@@ -132,7 +173,21 @@ module DecisionAgent
132
173
 
133
174
  private
134
175
 
135
- def all_versions
176
+ def list_versions_unsafe(rule_id:, limit: nil)
177
+ versions = []
178
+ rule_dir = File.join(@storage_path, sanitize_filename(rule_id))
179
+
180
+ return versions unless Dir.exist?(rule_dir)
181
+
182
+ Dir.glob(File.join(rule_dir, "*.json")).each do |file|
183
+ versions << JSON.parse(File.read(file), symbolize_names: true)
184
+ end
185
+
186
+ versions.sort_by! { |v| -v[:version_number] }
187
+ limit ? versions.take(limit) : versions
188
+ end
189
+
190
+ def all_versions_unsafe
136
191
  versions = []
137
192
  return versions unless Dir.exist?(@storage_path)
138
193
 
@@ -143,8 +198,17 @@ module DecisionAgent
143
198
  versions
144
199
  end
145
200
 
146
- def update_version_status(version_id, status)
147
- version = get_version(version_id: version_id)
201
+ def update_version_status_unsafe(version_id, status, rule_id = nil)
202
+ # Validate status first
203
+ validate_status!(status)
204
+
205
+ # Use provided rule_id or look it up from index
206
+ rule_id ||= get_rule_id_from_index(version_id)
207
+ return unless rule_id
208
+
209
+ # Read only this rule's versions
210
+ versions = list_versions_unsafe(rule_id: rule_id)
211
+ version = versions.find { |v| v[:id] == version_id }
148
212
  return unless version
149
213
 
150
214
  version[:status] = status
@@ -164,9 +228,11 @@ module DecisionAgent
164
228
  begin
165
229
  File.write(temp_file, JSON.pretty_generate(version))
166
230
  File.rename(temp_file, filepath)
231
+ # Update index after successful write
232
+ add_to_index(version[:id], version[:rule_id])
167
233
  ensure
168
234
  # Clean up temp file if rename failed
169
- File.delete(temp_file) if File.exist?(temp_file)
235
+ FileUtils.rm_f(temp_file)
170
236
  end
171
237
  end
172
238
 
@@ -177,6 +243,48 @@ module DecisionAgent
177
243
  def sanitize_filename(name)
178
244
  name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
179
245
  end
246
+
247
+ # Get or create a mutex for a specific rule_id
248
+ # This allows different rules to be processed in parallel
249
+ def with_rule_lock(rule_id, &block)
250
+ mutex = @rule_mutexes_lock.synchronize { @rule_mutexes[rule_id] }
251
+ mutex.synchronize(&block)
252
+ end
253
+
254
+ # Index management methods for O(1) version_id -> rule_id lookups
255
+ # This prevents the need to scan all 50,000 files when looking up a single version
256
+
257
+ def load_version_index
258
+ @version_index_lock.synchronize do
259
+ return unless Dir.exist?(@storage_path)
260
+
261
+ Dir.glob(File.join(@storage_path, "*", "*.json")).each do |file|
262
+ version = JSON.parse(File.read(file), symbolize_names: true)
263
+ @version_index[version[:id]] = version[:rule_id]
264
+ rescue JSON::ParserError
265
+ # Skip corrupted files
266
+ next
267
+ end
268
+ end
269
+ end
270
+
271
+ def get_rule_id_from_index(version_id)
272
+ @version_index_lock.synchronize do
273
+ @version_index[version_id]
274
+ end
275
+ end
276
+
277
+ def add_to_index(version_id, rule_id)
278
+ @version_index_lock.synchronize do
279
+ @version_index[version_id] = rule_id
280
+ end
281
+ end
282
+
283
+ def remove_from_index(version_id)
284
+ @version_index_lock.synchronize do
285
+ @version_index.delete(version_id)
286
+ end
287
+ end
180
288
  end
181
289
  end
182
290
  end
@@ -54,22 +54,14 @@ module DecisionAgent
54
54
  @adapter.get_active_version(rule_id: rule_id)
55
55
  end
56
56
 
57
- # Rollback to a previous version (activate it)
57
+ # Rollback to a previous version (activate it without creating a duplicate)
58
58
  # @param version_id [String, Integer] The version to rollback to
59
59
  # @param performed_by [String] User performing the rollback
60
60
  # @return [Hash] The activated version
61
61
  def rollback(version_id:, performed_by: "system")
62
- version = @adapter.activate_version(version_id: version_id)
63
-
64
- # Create an audit trail of the rollback
65
- save_version(
66
- rule_id: version[:rule_id],
67
- rule_content: version[:content],
68
- created_by: performed_by,
69
- changelog: "Rolled back to version #{version[:version_number]}"
70
- )
71
-
72
- version
62
+ # Simply activate the previous version without creating a duplicate
63
+ # The version history already contains the full record
64
+ @adapter.activate_version(version_id: version_id)
73
65
  end
74
66
 
75
67
  # Compare two versions
@@ -237,7 +237,7 @@
237
237
  </div>
238
238
 
239
239
  <footer class="footer">
240
- <p>DecisionAgent Rule Builder | <a href="https://github.com/yourusername/decision_agent" target="_blank">Documentation</a></p>
240
+ <p>DecisionAgent Rule Builder | <a href="https://github.com/samaswin87/decision_agent" target="_blank">Documentation</a></p>
241
241
  </footer>
242
242
 
243
243
  <script src="app.js"></script>
@@ -43,14 +43,12 @@ module DecisionAgent
43
43
  valid: true,
44
44
  message: "Rules are valid!"
45
45
  }.to_json
46
-
47
46
  rescue JSON::ParserError => e
48
47
  status 400
49
48
  {
50
49
  valid: false,
51
50
  errors: ["Invalid JSON: #{e.message}"]
52
51
  }.to_json
53
-
54
52
  rescue DecisionAgent::InvalidRuleDslError => e
55
53
  # Validation failed
56
54
  status 422
@@ -58,8 +56,7 @@ module DecisionAgent
58
56
  valid: false,
59
57
  errors: parse_validation_errors(e.message)
60
58
  }.to_json
61
-
62
- rescue => e
59
+ rescue StandardError => e
63
60
  # Unexpected error
64
61
  status 500
65
62
  {
@@ -102,8 +99,7 @@ module DecisionAgent
102
99
  message: "No rules matched the given context"
103
100
  }.to_json
104
101
  end
105
-
106
- rescue => e
102
+ rescue StandardError => e
107
103
  status 500
108
104
  {
109
105
  success: false,
@@ -136,7 +132,7 @@ module DecisionAgent
136
132
  },
137
133
  {
138
134
  id: "high_amount_review",
139
- if: { field: "amount", op: "gte", value: 10000 },
135
+ if: { field: "amount", op: "gte", value: 10_000 },
140
136
  then: { decision: "manual_review", weight: 0.9, reason: "High amount requires review" }
141
137
  }
142
138
  ]
@@ -247,8 +243,7 @@ module DecisionAgent
247
243
 
248
244
  status 201
249
245
  version.to_json
250
-
251
- rescue => e
246
+ rescue StandardError => e
252
247
  status 500
253
248
  { error: e.message }.to_json
254
249
  end
@@ -265,8 +260,7 @@ module DecisionAgent
265
260
  versions = version_manager.get_versions(rule_id: rule_id, limit: limit)
266
261
 
267
262
  versions.to_json
268
-
269
- rescue => e
263
+ rescue StandardError => e
270
264
  status 500
271
265
  { error: e.message }.to_json
272
266
  end
@@ -281,8 +275,7 @@ module DecisionAgent
281
275
  history = version_manager.get_history(rule_id: rule_id)
282
276
 
283
277
  history.to_json
284
-
285
- rescue => e
278
+ rescue StandardError => e
286
279
  status 500
287
280
  { error: e.message }.to_json
288
281
  end
@@ -302,8 +295,7 @@ module DecisionAgent
302
295
  status 404
303
296
  { error: "Version not found" }.to_json
304
297
  end
305
-
306
- rescue => e
298
+ rescue StandardError => e
307
299
  status 500
308
300
  { error: e.message }.to_json
309
301
  end
@@ -325,8 +317,7 @@ module DecisionAgent
325
317
  )
326
318
 
327
319
  version.to_json
328
-
329
- rescue => e
320
+ rescue StandardError => e
330
321
  status 500
331
322
  { error: e.message }.to_json
332
323
  end
@@ -351,8 +342,7 @@ module DecisionAgent
351
342
  status 404
352
343
  { error: "One or both versions not found" }.to_json
353
344
  end
354
-
355
- rescue => e
345
+ rescue StandardError => e
356
346
  status 500
357
347
  { error: e.message }.to_json
358
348
  end
@@ -369,16 +359,13 @@ module DecisionAgent
369
359
 
370
360
  status 200
371
361
  { success: true, message: "Version deleted successfully" }.to_json
372
-
373
362
  rescue DecisionAgent::NotFoundError => e
374
363
  status 404
375
364
  { error: e.message }.to_json
376
-
377
365
  rescue DecisionAgent::ValidationError => e
378
366
  status 422
379
367
  { error: e.message }.to_json
380
-
381
- rescue => e
368
+ rescue StandardError => e
382
369
  status 500
383
370
  { error: e.message }.to_json
384
371
  end
@@ -409,12 +396,20 @@ module DecisionAgent
409
396
  errors.empty? ? [error_message] : errors
410
397
  end
411
398
 
412
- # Class method to start the server
399
+ # Class method to start the server (for CLI usage)
413
400
  def self.start!(port: 4567, host: "0.0.0.0")
414
401
  set :port, port
415
402
  set :bind, host
416
403
  run!
417
404
  end
405
+
406
+ # Rack interface for mounting in Rails/Rack apps
407
+ # Example:
408
+ # # config/routes.rb
409
+ # mount DecisionAgent::Web::Server, at: "/decision_agent"
410
+ def self.call(env)
411
+ new.call(env)
412
+ end
418
413
  end
419
414
  end
420
415
  end
@@ -2,6 +2,7 @@ require_relative "decision_agent/version"
2
2
  require_relative "decision_agent/errors"
3
3
  require_relative "decision_agent/context"
4
4
  require_relative "decision_agent/evaluation"
5
+ require_relative "decision_agent/evaluation_validator"
5
6
  require_relative "decision_agent/decision"
6
7
  require_relative "decision_agent/agent"
7
8
 
@@ -29,5 +30,11 @@ require_relative "decision_agent/versioning/adapter"
29
30
  require_relative "decision_agent/versioning/file_storage_adapter"
30
31
  require_relative "decision_agent/versioning/version_manager"
31
32
 
33
+ require_relative "decision_agent/monitoring/metrics_collector"
34
+ require_relative "decision_agent/monitoring/prometheus_exporter"
35
+ require_relative "decision_agent/monitoring/alert_manager"
36
+ require_relative "decision_agent/monitoring/monitored_agent"
37
+ # dashboard_server has additional dependencies (faye/websocket) - require it explicitly when needed
38
+
32
39
  module DecisionAgent
33
40
  end
@@ -1,12 +1,12 @@
1
- require 'rails/generators'
2
- require 'rails/generators/migration'
1
+ require "rails/generators"
2
+ require "rails/generators/migration"
3
3
 
4
4
  module DecisionAgent
5
5
  module Generators
6
6
  class InstallGenerator < Rails::Generators::Base
7
7
  include Rails::Generators::Migration
8
8
 
9
- source_root File.expand_path('templates', __dir__)
9
+ source_root File.expand_path("templates", __dir__)
10
10
 
11
11
  desc "Installs DecisionAgent models and migrations for Rails"
12
12
 
@@ -17,8 +17,8 @@ module DecisionAgent
17
17
 
18
18
  def copy_migration
19
19
  migration_template "migration.rb",
20
- "db/migrate/create_decision_agent_tables.rb",
21
- migration_version: migration_version
20
+ "db/migrate/create_decision_agent_tables.rb",
21
+ migration_version: migration_version
22
22
  end
23
23
 
24
24
  def copy_models
@@ -5,7 +5,7 @@ class CreateDecisionAgentTables < ActiveRecord::Migration[7.0]
5
5
  t.string :rule_id, null: false, index: { unique: true }
6
6
  t.string :ruleset, null: false
7
7
  t.text :description
8
- t.string :status, default: 'active'
8
+ t.string :status, default: "active"
9
9
  t.timestamps
10
10
  end
11
11
 
@@ -13,14 +13,25 @@ class CreateDecisionAgentTables < ActiveRecord::Migration[7.0]
13
13
  create_table :rule_versions do |t|
14
14
  t.string :rule_id, null: false, index: true
15
15
  t.integer :version_number, null: false
16
- t.text :content, null: false # JSON rule definition
17
- t.string :created_by, null: false, default: 'system'
16
+ t.text :content, null: false # JSON rule definition
17
+ t.string :created_by, null: false, default: "system"
18
18
  t.text :changelog
19
- t.string :status, null: false, default: 'draft' # draft, active, archived
19
+ t.string :status, null: false, default: "draft" # draft, active, archived
20
20
  t.timestamps
21
21
  end
22
22
 
23
- add_index :rule_versions, [:rule_id, :version_number], unique: true
24
- add_index :rule_versions, [:rule_id, :status]
23
+ # CRITICAL: Unique constraint prevents duplicate version numbers per rule
24
+ # This protects against race conditions in concurrent version creation
25
+ add_index :rule_versions, %i[rule_id version_number], unique: true
26
+
27
+ # Index for efficient queries by rule_id and status
28
+ add_index :rule_versions, %i[rule_id status]
29
+
30
+ # Optional: Partial unique index for PostgreSQL to enforce one active version per rule
31
+ # Uncomment if using PostgreSQL:
32
+ # add_index :rule_versions, [:rule_id, :status],
33
+ # unique: true,
34
+ # where: "status = 'active'",
35
+ # name: 'index_rule_versions_one_active_per_rule'
25
36
  end
26
37
  end
@@ -5,12 +5,12 @@ class Rule < ApplicationRecord
5
5
  validates :ruleset, presence: true
6
6
  validates :status, inclusion: { in: %w[active inactive archived] }
7
7
 
8
- scope :active, -> { where(status: 'active') }
8
+ scope :active, -> { where(status: "active") }
9
9
  scope :by_ruleset, ->(ruleset) { where(ruleset: ruleset) }
10
10
 
11
11
  # Get the active version for this rule
12
12
  def active_version
13
- rule_versions.find_by(status: 'active')
13
+ rule_versions.find_by(status: "active")
14
14
  end
15
15
 
16
16
  # Get all versions ordered by version number
@@ -19,7 +19,7 @@ class Rule < ApplicationRecord
19
19
  end
20
20
 
21
21
  # Create a new version
22
- def create_version(content:, created_by: 'system', changelog: nil)
22
+ def create_version(content:, created_by: "system", changelog: nil)
23
23
  DecisionAgent::Versioning::VersionManager.new.save_version(
24
24
  rule_id: rule_id,
25
25
  rule_content: content,
@@ -7,7 +7,7 @@ class RuleVersion < ApplicationRecord
7
7
  validates :status, inclusion: { in: %w[draft active archived] }
8
8
  validates :created_by, presence: true
9
9
 
10
- scope :active, -> { where(status: 'active') }
10
+ scope :active, -> { where(status: "active") }
11
11
  scope :for_rule, ->(rule_id) { where(rule_id: rule_id).order(version_number: :desc) }
12
12
  scope :latest, -> { order(version_number: :desc).limit(1) }
13
13
 
@@ -29,12 +29,15 @@ class RuleVersion < ApplicationRecord
29
29
  def activate!
30
30
  transaction do
31
31
  # Deactivate all other versions for this rule
32
- self.class.where(rule_id: rule_id, status: 'active')
33
- .where.not(id: id)
34
- .update_all(status: 'archived')
32
+ # Use update! instead of update_all to trigger validations
33
+ self.class.where(rule_id: rule_id, status: "active")
34
+ .where.not(id: id)
35
+ .find_each do |v|
36
+ v.update!(status: "archived")
37
+ end
35
38
 
36
39
  # Activate this version
37
- update!(status: 'active')
40
+ update!(status: "active")
38
41
  end
39
42
  end
40
43
 
@@ -51,9 +54,12 @@ class RuleVersion < ApplicationRecord
51
54
  def set_next_version_number
52
55
  return if version_number.present?
53
56
 
57
+ # Use pessimistic locking to prevent race conditions when calculating version numbers
58
+ # Lock the last version record to ensure only one thread can read and increment at a time
54
59
  last_version = self.class.where(rule_id: rule_id)
55
- .order(version_number: :desc)
56
- .first
60
+ .order(version_number: :desc)
61
+ .lock
62
+ .first
57
63
 
58
64
  self.version_number = last_version ? last_version.version_number + 1 : 1
59
65
  end