decision_agent 0.1.1 → 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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +234 -919
  3. data/bin/decision_agent +5 -5
  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 +21 -6
  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 +141 -0
  29. data/lib/decision_agent/versioning/adapter.rb +100 -0
  30. data/lib/decision_agent/versioning/file_storage_adapter.rb +290 -0
  31. data/lib/decision_agent/versioning/version_manager.rb +127 -0
  32. data/lib/decision_agent/web/public/app.js +318 -0
  33. data/lib/decision_agent/web/public/index.html +56 -1
  34. data/lib/decision_agent/web/public/styles.css +219 -0
  35. data/lib/decision_agent/web/server.rb +169 -9
  36. data/lib/decision_agent.rb +11 -0
  37. data/lib/generators/decision_agent/install/install_generator.rb +40 -0
  38. data/lib/generators/decision_agent/install/templates/README +47 -0
  39. data/lib/generators/decision_agent/install/templates/migration.rb +37 -0
  40. data/lib/generators/decision_agent/install/templates/rule.rb +30 -0
  41. data/lib/generators/decision_agent/install/templates/rule_version.rb +66 -0
  42. data/spec/activerecord_thread_safety_spec.rb +553 -0
  43. data/spec/agent_spec.rb +13 -13
  44. data/spec/api_contract_spec.rb +16 -16
  45. data/spec/audit_adapters_spec.rb +3 -3
  46. data/spec/comprehensive_edge_cases_spec.rb +86 -86
  47. data/spec/dsl_validation_spec.rb +83 -83
  48. data/spec/edge_cases_spec.rb +23 -23
  49. data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
  50. data/spec/examples.txt +548 -0
  51. data/spec/issue_verification_spec.rb +685 -0
  52. data/spec/json_rule_evaluator_spec.rb +15 -15
  53. data/spec/monitoring/alert_manager_spec.rb +378 -0
  54. data/spec/monitoring/metrics_collector_spec.rb +281 -0
  55. data/spec/monitoring/monitored_agent_spec.rb +222 -0
  56. data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
  57. data/spec/replay_edge_cases_spec.rb +58 -58
  58. data/spec/replay_spec.rb +11 -11
  59. data/spec/rfc8785_canonicalization_spec.rb +215 -0
  60. data/spec/scoring_spec.rb +1 -1
  61. data/spec/spec_helper.rb +9 -0
  62. data/spec/thread_safety_spec.rb +482 -0
  63. data/spec/thread_safety_spec.rb.broken +878 -0
  64. data/spec/versioning_spec.rb +777 -0
  65. data/spec/web_ui_rack_spec.rb +135 -0
  66. metadata +84 -11
@@ -0,0 +1,777 @@
1
+ require "spec_helper"
2
+ require "fileutils"
3
+ require "tempfile"
4
+
5
+ RSpec.describe "DecisionAgent Versioning System" do
6
+ describe DecisionAgent::Versioning::FileStorageAdapter do
7
+ let(:temp_dir) { Dir.mktmpdir }
8
+ let(:adapter) { described_class.new(storage_path: temp_dir) }
9
+ let(:rule_id) { "test_rule_001" }
10
+ let(:rule_content) do
11
+ {
12
+ version: "1.0",
13
+ ruleset: "test_ruleset",
14
+ rules: [
15
+ {
16
+ id: "rule_1",
17
+ if: { field: "amount", op: "gt", value: 100 },
18
+ then: { decision: "approve", weight: 0.8, reason: "High value" }
19
+ }
20
+ ]
21
+ }
22
+ end
23
+
24
+ after do
25
+ FileUtils.rm_rf(temp_dir)
26
+ end
27
+
28
+ describe "#create_version" do
29
+ it "creates a new version with version number 1" do
30
+ version = adapter.create_version(
31
+ rule_id: rule_id,
32
+ content: rule_content,
33
+ metadata: { created_by: "test_user", changelog: "Initial version" }
34
+ )
35
+
36
+ expect(version[:version_number]).to eq(1)
37
+ expect(version[:rule_id]).to eq(rule_id)
38
+ expect(version[:content]).to eq(rule_content)
39
+ expect(version[:created_by]).to eq("test_user")
40
+ expect(version[:changelog]).to eq("Initial version")
41
+ expect(version[:status]).to eq("active")
42
+ end
43
+
44
+ it "auto-increments version numbers" do
45
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
46
+ v2 = adapter.create_version(rule_id: rule_id, content: rule_content)
47
+ v3 = adapter.create_version(rule_id: rule_id, content: rule_content)
48
+
49
+ expect(v1[:version_number]).to eq(1)
50
+ expect(v2[:version_number]).to eq(2)
51
+ expect(v3[:version_number]).to eq(3)
52
+ end
53
+
54
+ it "deactivates previous active versions" do
55
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
56
+ v2 = adapter.create_version(rule_id: rule_id, content: rule_content)
57
+
58
+ versions = adapter.list_versions(rule_id: rule_id)
59
+ expect(versions.find { |v| v[:id] == v1[:id] }[:status]).to eq("archived")
60
+ expect(versions.find { |v| v[:id] == v2[:id] }[:status]).to eq("active")
61
+ end
62
+
63
+ it "persists versions to disk" do
64
+ version = adapter.create_version(rule_id: rule_id, content: rule_content)
65
+
66
+ # Create new adapter instance to verify persistence
67
+ new_adapter = described_class.new(storage_path: temp_dir)
68
+ loaded_version = new_adapter.get_version(version_id: version[:id])
69
+
70
+ expect(loaded_version).to eq(version)
71
+ end
72
+ end
73
+
74
+ describe "#list_versions" do
75
+ it "returns empty array when no versions exist" do
76
+ versions = adapter.list_versions(rule_id: "nonexistent")
77
+ expect(versions).to eq([])
78
+ end
79
+
80
+ it "returns all versions for a rule ordered by version number descending" do
81
+ adapter.create_version(rule_id: rule_id, content: rule_content)
82
+ adapter.create_version(rule_id: rule_id, content: rule_content)
83
+ adapter.create_version(rule_id: rule_id, content: rule_content)
84
+
85
+ versions = adapter.list_versions(rule_id: rule_id)
86
+
87
+ expect(versions.map { |v| v[:version_number] }).to eq([3, 2, 1])
88
+ end
89
+
90
+ it "respects limit parameter" do
91
+ 5.times { adapter.create_version(rule_id: rule_id, content: rule_content) }
92
+
93
+ versions = adapter.list_versions(rule_id: rule_id, limit: 2)
94
+ expect(versions.length).to eq(2)
95
+ end
96
+ end
97
+
98
+ describe "#get_version" do
99
+ it "returns nil for nonexistent version" do
100
+ version = adapter.get_version(version_id: "nonexistent")
101
+ expect(version).to be_nil
102
+ end
103
+
104
+ it "returns the correct version by ID" do
105
+ adapter.create_version(rule_id: rule_id, content: rule_content)
106
+ v2 = adapter.create_version(rule_id: rule_id, content: rule_content.merge(version: "2.0"))
107
+
108
+ loaded = adapter.get_version(version_id: v2[:id])
109
+ expect(loaded[:version_number]).to eq(2)
110
+ expect(loaded[:content][:version]).to eq("2.0")
111
+ end
112
+ end
113
+
114
+ describe "#get_version_by_number" do
115
+ it "returns the correct version by rule_id and version_number" do
116
+ adapter.create_version(rule_id: rule_id, content: rule_content)
117
+ v2 = adapter.create_version(rule_id: rule_id, content: rule_content.merge(version: "2.0"))
118
+
119
+ loaded = adapter.get_version_by_number(rule_id: rule_id, version_number: 2)
120
+ expect(loaded[:id]).to eq(v2[:id])
121
+ expect(loaded[:content][:version]).to eq("2.0")
122
+ end
123
+
124
+ it "returns nil if version number doesn't exist" do
125
+ version = adapter.get_version_by_number(rule_id: rule_id, version_number: 999)
126
+ expect(version).to be_nil
127
+ end
128
+ end
129
+
130
+ describe "#get_active_version" do
131
+ it "returns nil when no active version exists" do
132
+ version = adapter.get_active_version(rule_id: "nonexistent")
133
+ expect(version).to be_nil
134
+ end
135
+
136
+ it "returns the currently active version" do
137
+ adapter.create_version(rule_id: rule_id, content: rule_content)
138
+ v2 = adapter.create_version(rule_id: rule_id, content: rule_content)
139
+
140
+ active = adapter.get_active_version(rule_id: rule_id)
141
+ expect(active[:id]).to eq(v2[:id])
142
+ expect(active[:status]).to eq("active")
143
+ end
144
+ end
145
+
146
+ describe "#activate_version" do
147
+ it "activates a version and deactivates others" do
148
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
149
+ v2 = adapter.create_version(rule_id: rule_id, content: rule_content)
150
+
151
+ adapter.activate_version(version_id: v1[:id])
152
+
153
+ versions = adapter.list_versions(rule_id: rule_id)
154
+ expect(versions.find { |v| v[:id] == v1[:id] }[:status]).to eq("active")
155
+ expect(versions.find { |v| v[:id] == v2[:id] }[:status]).to eq("archived")
156
+ end
157
+
158
+ it "raises error for nonexistent version" do
159
+ expect do
160
+ adapter.activate_version(version_id: "nonexistent")
161
+ end.to raise_error(DecisionAgent::NotFoundError)
162
+ end
163
+ end
164
+
165
+ describe "#compare_versions" do
166
+ it "returns comparison with differences" do
167
+ content1 = rule_content
168
+ content2 = rule_content.merge(version: "2.0")
169
+
170
+ v1 = adapter.create_version(rule_id: rule_id, content: content1)
171
+ v2 = adapter.create_version(rule_id: rule_id, content: content2)
172
+
173
+ comparison = adapter.compare_versions(version_id_1: v1[:id], version_id_2: v2[:id])
174
+
175
+ expect(comparison[:version_1][:id]).to eq(v1[:id])
176
+ expect(comparison[:version_2][:id]).to eq(v2[:id])
177
+ expect(comparison[:differences]).to have_key(:added)
178
+ expect(comparison[:differences]).to have_key(:removed)
179
+ expect(comparison[:differences]).to have_key(:changed)
180
+ end
181
+
182
+ it "returns nil if either version doesn't exist" do
183
+ v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
184
+
185
+ comparison = adapter.compare_versions(version_id_1: v1[:id], version_id_2: "nonexistent")
186
+ expect(comparison).to be_nil
187
+ end
188
+ end
189
+ end
190
+
191
+ describe DecisionAgent::Versioning::VersionManager do
192
+ let(:temp_dir) { Dir.mktmpdir }
193
+ let(:adapter) { DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: temp_dir) }
194
+ let(:manager) { described_class.new(adapter: adapter) }
195
+ let(:rule_id) { "test_rule_001" }
196
+ let(:rule_content) do
197
+ {
198
+ version: "1.0",
199
+ ruleset: "test_ruleset",
200
+ rules: [
201
+ {
202
+ id: "rule_1",
203
+ if: { field: "amount", op: "gt", value: 100 },
204
+ then: { decision: "approve", weight: 0.8, reason: "High value" }
205
+ }
206
+ ]
207
+ }
208
+ end
209
+
210
+ after do
211
+ FileUtils.rm_rf(temp_dir)
212
+ end
213
+
214
+ describe "#save_version" do
215
+ it "creates a version with metadata" do
216
+ version = manager.save_version(
217
+ rule_id: rule_id,
218
+ rule_content: rule_content,
219
+ created_by: "admin",
220
+ changelog: "Initial version"
221
+ )
222
+
223
+ expect(version[:rule_id]).to eq(rule_id)
224
+ expect(version[:content]).to eq(rule_content)
225
+ expect(version[:created_by]).to eq("admin")
226
+ expect(version[:changelog]).to eq("Initial version")
227
+ end
228
+
229
+ it "validates rule content" do
230
+ expect do
231
+ manager.save_version(rule_id: rule_id, rule_content: nil)
232
+ end.to raise_error(DecisionAgent::ValidationError, /cannot be nil/)
233
+
234
+ expect do
235
+ manager.save_version(rule_id: rule_id, rule_content: "not a hash")
236
+ end.to raise_error(DecisionAgent::ValidationError, /must be a Hash/)
237
+
238
+ expect do
239
+ manager.save_version(rule_id: rule_id, rule_content: {})
240
+ end.to raise_error(DecisionAgent::ValidationError, /cannot be empty/)
241
+ end
242
+
243
+ it "generates default changelog if not provided" do
244
+ version = manager.save_version(rule_id: rule_id, rule_content: rule_content)
245
+ expect(version[:changelog]).to match(/Version \d+/)
246
+ end
247
+ end
248
+
249
+ describe "#get_versions" do
250
+ it "returns all versions for a rule" do
251
+ 3.times { manager.save_version(rule_id: rule_id, rule_content: rule_content) }
252
+
253
+ versions = manager.get_versions(rule_id: rule_id)
254
+ expect(versions.length).to eq(3)
255
+ end
256
+
257
+ it "respects limit" do
258
+ 5.times { manager.save_version(rule_id: rule_id, rule_content: rule_content) }
259
+
260
+ versions = manager.get_versions(rule_id: rule_id, limit: 2)
261
+ expect(versions.length).to eq(2)
262
+ end
263
+ end
264
+
265
+ describe "#rollback" do
266
+ it "activates a previous version without creating a duplicate" do
267
+ v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "v1")
268
+ manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "v2")
269
+ manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "v3")
270
+
271
+ # Rollback to v1 should just activate it, not create a duplicate
272
+ rolled_back = manager.rollback(version_id: v1[:id], performed_by: "admin")
273
+
274
+ expect(rolled_back[:status]).to eq("active")
275
+ expect(rolled_back[:id]).to eq(v1[:id])
276
+
277
+ # Should NOT create a new version - just activate the old one
278
+ versions = manager.get_versions(rule_id: rule_id)
279
+ expect(versions.length).to eq(3) # Still just v1, v2, v3
280
+
281
+ # v1 should be active, v2 and v3 should be archived
282
+ active_version = manager.get_active_version(rule_id: rule_id)
283
+ expect(active_version[:id]).to eq(v1[:id])
284
+ expect(active_version[:version_number]).to eq(1)
285
+ end
286
+
287
+ it "maintains version history integrity after rollback" do
288
+ v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content.merge(data: "v1"), changelog: "Version 1")
289
+ v2 = manager.save_version(rule_id: rule_id, rule_content: rule_content.merge(data: "v2"), changelog: "Version 2")
290
+ v3 = manager.save_version(rule_id: rule_id, rule_content: rule_content.merge(data: "v3"), changelog: "Version 3")
291
+
292
+ # Rollback to v2
293
+ manager.rollback(version_id: v2[:id])
294
+
295
+ # All original versions should still exist with original data
296
+ loaded_v1 = manager.get_version(version_id: v1[:id])
297
+ loaded_v2 = manager.get_version(version_id: v2[:id])
298
+ loaded_v3 = manager.get_version(version_id: v3[:id])
299
+
300
+ expect(loaded_v1[:content][:data]).to eq("v1")
301
+ expect(loaded_v2[:content][:data]).to eq("v2")
302
+ expect(loaded_v3[:content][:data]).to eq("v3")
303
+
304
+ # v2 should be active
305
+ expect(loaded_v2[:status]).to eq("active")
306
+ expect(loaded_v1[:status]).to eq("archived")
307
+ expect(loaded_v3[:status]).to eq("archived")
308
+ end
309
+ end
310
+
311
+ describe "#get_history" do
312
+ it "returns comprehensive history with metadata" do
313
+ manager.save_version(rule_id: rule_id, rule_content: rule_content)
314
+ manager.save_version(rule_id: rule_id, rule_content: rule_content)
315
+
316
+ history = manager.get_history(rule_id: rule_id)
317
+
318
+ expect(history[:rule_id]).to eq(rule_id)
319
+ expect(history[:total_versions]).to eq(2)
320
+ expect(history[:active_version]).not_to be_nil
321
+ expect(history[:versions]).to be_an(Array)
322
+ expect(history[:created_at]).not_to be_nil
323
+ expect(history[:updated_at]).not_to be_nil
324
+ end
325
+ end
326
+
327
+ describe "edge cases and error handling" do
328
+ it "handles empty rule_id gracefully" do
329
+ expect do
330
+ manager.save_version(rule_id: "", rule_content: rule_content)
331
+ end.not_to raise_error
332
+ end
333
+
334
+ it "handles special characters in rule_id" do
335
+ special_rule_id = "rule-with_special.chars@123"
336
+ version = manager.save_version(rule_id: special_rule_id, rule_content: rule_content)
337
+
338
+ expect(version[:rule_id]).to eq(special_rule_id)
339
+ end
340
+
341
+ it "handles large rule content" do
342
+ large_content = {
343
+ version: "1.0",
344
+ ruleset: "large_ruleset",
345
+ rules: Array.new(1000) do |i|
346
+ {
347
+ id: "rule_#{i}",
348
+ if: { field: "value", op: "eq", value: i },
349
+ then: { decision: "approve", weight: 0.5, reason: "Rule #{i}" }
350
+ }
351
+ end
352
+ }
353
+
354
+ version = manager.save_version(rule_id: rule_id, rule_content: large_content)
355
+ expect(version[:content][:rules].length).to eq(1000)
356
+ end
357
+
358
+ it "handles deeply nested rule structures" do
359
+ nested_content = {
360
+ version: "1.0",
361
+ ruleset: "nested",
362
+ rules: [
363
+ {
364
+ id: "nested_rule",
365
+ if: {
366
+ all: [
367
+ {
368
+ any: [
369
+ { field: "a", op: "eq", value: 1 },
370
+ { field: "b", op: "eq", value: 2 }
371
+ ]
372
+ },
373
+ {
374
+ all: [
375
+ { field: "c", op: "gt", value: 3 },
376
+ { field: "d", op: "lt", value: 4 }
377
+ ]
378
+ }
379
+ ]
380
+ },
381
+ then: { decision: "approve", weight: 0.8, reason: "Complex rule" }
382
+ }
383
+ ]
384
+ }
385
+
386
+ version = manager.save_version(rule_id: rule_id, rule_content: nested_content)
387
+ expect(version[:content][:rules].first[:if][:all]).to be_an(Array)
388
+ end
389
+
390
+ it "preserves exact content structure including symbols and strings" do
391
+ mixed_content = {
392
+ version: "1.0",
393
+ ruleset: "mixed",
394
+ rules: [
395
+ {
396
+ id: "test",
397
+ metadata: {
398
+ string_key: "value",
399
+ number_key: 123,
400
+ boolean_key: true,
401
+ null_key: nil,
402
+ array_key: [1, 2, 3]
403
+ },
404
+ if: { field: "test", op: "eq", value: "value" },
405
+ then: { decision: "approve", weight: 0.5, reason: "Test" }
406
+ }
407
+ ]
408
+ }
409
+
410
+ version = manager.save_version(rule_id: rule_id, rule_content: mixed_content)
411
+ loaded = manager.get_version(version_id: version[:id])
412
+
413
+ expect(loaded[:content][:rules].first[:metadata]).to eq(mixed_content[:rules].first[:metadata])
414
+ end
415
+ end
416
+
417
+ describe "concurrent version creation" do
418
+ it "maintains version number sequence with concurrent saves" do
419
+ threads = 10.times.map do |i|
420
+ Thread.new do
421
+ manager.save_version(
422
+ rule_id: rule_id,
423
+ rule_content: rule_content.merge(version: i.to_s),
424
+ created_by: "thread_#{i}"
425
+ )
426
+ end
427
+ end
428
+
429
+ threads.each(&:join)
430
+
431
+ versions = manager.get_versions(rule_id: rule_id)
432
+ version_numbers = versions.map { |v| v[:version_number] }.sort
433
+
434
+ expect(version_numbers).to eq((1..10).to_a)
435
+ end
436
+ end
437
+
438
+ describe "version lifecycle" do
439
+ it "tracks complete version lifecycle from draft to archived" do
440
+ # Create as draft
441
+ v1 = adapter.create_version(
442
+ rule_id: rule_id,
443
+ content: rule_content,
444
+ metadata: { status: "draft" }
445
+ )
446
+ expect(v1[:status]).to eq("draft")
447
+
448
+ # Activate
449
+ adapter.activate_version(version_id: v1[:id])
450
+ v1_updated = adapter.get_version(version_id: v1[:id])
451
+ expect(v1_updated[:status]).to eq("active")
452
+
453
+ # Create new version (archives previous)
454
+ v2 = adapter.create_version(rule_id: rule_id, content: rule_content)
455
+ v1_archived = adapter.get_version(version_id: v1[:id])
456
+ expect(v1_archived[:status]).to eq("archived")
457
+ expect(v2[:status]).to eq("active")
458
+ end
459
+ end
460
+
461
+ describe "comparison edge cases" do
462
+ it "compares identical versions" do
463
+ v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
464
+ v2 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
465
+
466
+ comparison = manager.compare(version_id_1: v1[:id], version_id_2: v2[:id])
467
+
468
+ # Should have minimal differences (just metadata changes)
469
+ expect(comparison[:differences][:added]).to be_empty
470
+ expect(comparison[:differences][:removed]).to be_empty
471
+ end
472
+
473
+ it "detects all types of changes" do
474
+ content_v1 = {
475
+ version: "1.0",
476
+ ruleset: "test",
477
+ rules: [
478
+ { id: "rule_1", if: { field: "a", op: "eq", value: 1 }, then: { decision: "approve", weight: 0.8, reason: "Test" } }
479
+ ]
480
+ }
481
+
482
+ content_v2 = {
483
+ version: "2.0", # changed
484
+ ruleset: "test",
485
+ rules: [
486
+ { id: "rule_1", if: { field: "a", op: "eq", value: 2 }, then: { decision: "reject", weight: 0.9, reason: "Updated" } }, # modified
487
+ { id: "rule_2", if: { field: "b", op: "gt", value: 100 }, then: { decision: "approve", weight: 0.7, reason: "New" } } # added
488
+ ],
489
+ new_field: "added" # added field
490
+ }
491
+
492
+ v1 = manager.save_version(rule_id: rule_id, rule_content: content_v1)
493
+ v2 = manager.save_version(rule_id: rule_id, rule_content: content_v2)
494
+
495
+ comparison = manager.compare(version_id_1: v1[:id], version_id_2: v2[:id])
496
+
497
+ expect(comparison[:differences][:added].length).to be > 0
498
+ expect(comparison[:differences][:changed]).to have_key(:version)
499
+ end
500
+ end
501
+
502
+ describe "rollback scenarios" do
503
+ it "activates previous version without creating duplicates" do
504
+ v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version 1")
505
+ manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version 2")
506
+ manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version 3")
507
+
508
+ manager.rollback(version_id: v1[:id], performed_by: "admin")
509
+
510
+ history = manager.get_history(rule_id: rule_id)
511
+ expect(history[:total_versions]).to eq(3) # Still just v1, v2, v3 - no duplicate
512
+
513
+ # v1 should be the active version
514
+ expect(history[:active_version][:id]).to eq(v1[:id])
515
+ expect(history[:active_version][:changelog]).to eq("Version 1")
516
+ end
517
+
518
+ it "handles multiple consecutive rollbacks without duplication" do
519
+ v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
520
+ v2 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
521
+ v3 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
522
+
523
+ # Rollback to v1
524
+ result1 = manager.rollback(version_id: v1[:id], performed_by: "user1")
525
+ expect(result1[:id]).to eq(v1[:id])
526
+
527
+ # Rollback to v2
528
+ result2 = manager.rollback(version_id: v2[:id], performed_by: "user2")
529
+ expect(result2[:id]).to eq(v2[:id])
530
+
531
+ # Rollback to v3
532
+ result3 = manager.rollback(version_id: v3[:id], performed_by: "user3")
533
+ expect(result3[:id]).to eq(v3[:id])
534
+
535
+ history = manager.get_history(rule_id: rule_id)
536
+ expect(history[:total_versions]).to eq(3) # Still just the original 3 versions
537
+ expect(history[:active_version][:id]).to eq(v3[:id])
538
+ end
539
+ end
540
+
541
+ describe "query and filtering" do
542
+ it "filters versions by limit correctly" do
543
+ 20.times { |i| manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version #{i + 1}") }
544
+
545
+ versions_5 = manager.get_versions(rule_id: rule_id, limit: 5)
546
+ versions_10 = manager.get_versions(rule_id: rule_id, limit: 10)
547
+
548
+ expect(versions_5.length).to eq(5)
549
+ expect(versions_10.length).to eq(10)
550
+
551
+ # Most recent versions should come first
552
+ expect(versions_5.first[:version_number]).to eq(20)
553
+ expect(versions_5.last[:version_number]).to eq(16)
554
+ end
555
+
556
+ it "handles versions across multiple rules" do
557
+ rule_ids = %w[rule_a rule_b rule_c]
558
+
559
+ rule_ids.each do |rid|
560
+ 3.times { manager.save_version(rule_id: rid, rule_content: rule_content) }
561
+
562
+ versions = manager.get_versions(rule_id: rid)
563
+ expect(versions.length).to eq(3)
564
+ expect(versions.all? { |v| v[:rule_id] == rid }).to be true
565
+ end
566
+ end
567
+ end
568
+
569
+ describe "error recovery" do
570
+ it "maintains data integrity after failed save" do
571
+ # This test ensures that even if there's an error, previous versions remain intact
572
+ manager.save_version(rule_id: rule_id, rule_content: rule_content)
573
+
574
+ begin
575
+ manager.save_version(rule_id: rule_id, rule_content: nil) # This should fail
576
+ rescue DecisionAgent::ValidationError
577
+ # Expected error
578
+ end
579
+
580
+ # Previous version should still be accessible
581
+ versions = manager.get_versions(rule_id: rule_id)
582
+ expect(versions.length).to eq(1)
583
+ expect(versions.first[:content]).to eq(rule_content)
584
+ end
585
+ end
586
+ end
587
+
588
+ describe "Integration Tests" do
589
+ let(:temp_dir) { Dir.mktmpdir }
590
+ let(:adapter) { DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: temp_dir) }
591
+ let(:manager) { DecisionAgent::Versioning::VersionManager.new(adapter: adapter) }
592
+
593
+ after do
594
+ FileUtils.rm_rf(temp_dir)
595
+ end
596
+
597
+ describe "real-world workflow" do
598
+ it "handles a complete version management workflow" do
599
+ # 1. Create initial rule
600
+ approval_rule = {
601
+ version: "1.0",
602
+ ruleset: "approval_workflow",
603
+ rules: [
604
+ {
605
+ id: "high_value",
606
+ if: { field: "amount", op: "gt", value: 1000 },
607
+ then: { decision: "approve", weight: 0.9, reason: "High value customer" }
608
+ }
609
+ ]
610
+ }
611
+
612
+ v1 = manager.save_version(
613
+ rule_id: "approval_001",
614
+ rule_content: approval_rule,
615
+ created_by: "product_manager",
616
+ changelog: "Initial approval rules"
617
+ )
618
+
619
+ expect(v1[:version_number]).to eq(1)
620
+
621
+ # 2. Update threshold
622
+ approval_rule[:rules].first[:if][:value] = 5000
623
+ v2 = manager.save_version(
624
+ rule_id: "approval_001",
625
+ rule_content: approval_rule,
626
+ created_by: "compliance_officer",
627
+ changelog: "Increased threshold per compliance requirements"
628
+ )
629
+
630
+ expect(v2[:version_number]).to eq(2)
631
+
632
+ # 3. Add new rule
633
+ approval_rule[:rules] << {
634
+ id: "fraud_check",
635
+ if: { field: "fraud_score", op: "gt", value: 0.7 },
636
+ then: { decision: "reject", weight: 1.0, reason: "High fraud risk" }
637
+ }
638
+
639
+ v3 = manager.save_version(
640
+ rule_id: "approval_001",
641
+ rule_content: approval_rule,
642
+ created_by: "security_team",
643
+ changelog: "Added fraud detection rule"
644
+ )
645
+
646
+ expect(v3[:version_number]).to eq(3)
647
+ expect(v3[:content][:rules].length).to eq(2)
648
+
649
+ # 4. Compare versions
650
+ comparison = manager.compare(version_id_1: v1[:id], version_id_2: v3[:id])
651
+ expect(comparison[:version_1][:version_number]).to eq(1)
652
+ expect(comparison[:version_2][:version_number]).to eq(3)
653
+
654
+ # 5. Rollback due to issue
655
+ rolled_back = manager.rollback(
656
+ version_id: v2[:id],
657
+ performed_by: "incident_responder"
658
+ )
659
+
660
+ expect(rolled_back[:status]).to eq("active")
661
+ expect(rolled_back[:id]).to eq(v2[:id])
662
+
663
+ # 6. Verify history
664
+ history = manager.get_history(rule_id: "approval_001")
665
+ expect(history[:total_versions]).to eq(3) # v1, v2, v3 - no duplicate created
666
+ expect(history[:active_version][:version_number]).to eq(2) # v2 is active
667
+ end
668
+ end
669
+
670
+ describe "multi-rule management" do
671
+ it "manages versions for multiple related rules" do
672
+ rulesets = {
673
+ "approval" => {
674
+ version: "1.0",
675
+ ruleset: "approval",
676
+ rules: [{ id: "approve_1", if: { field: "amount", op: "lt", value: 100 }, then: { decision: "approve", weight: 0.8, reason: "Low amount" } }]
677
+ },
678
+ "rejection" => {
679
+ version: "1.0",
680
+ ruleset: "rejection",
681
+ rules: [{ id: "reject_1", if: { field: "risk_score", op: "gt", value: 0.9 }, then: { decision: "reject", weight: 1.0, reason: "High risk" } }]
682
+ },
683
+ "review" => {
684
+ version: "1.0",
685
+ ruleset: "review",
686
+ rules: [{ id: "review_1", if: { field: "amount", op: "gte", value: 10_000 }, then: { decision: "manual_review", weight: 0.9, reason: "Large transaction" } }]
687
+ }
688
+ }
689
+
690
+ # Create versions for all rulesets
691
+ rulesets.each do |name, content|
692
+ manager.save_version(
693
+ rule_id: name,
694
+ rule_content: content,
695
+ created_by: "system",
696
+ changelog: "Initial #{name} rules"
697
+ )
698
+ end
699
+
700
+ # Verify each has its own version history
701
+ rulesets.each_key do |name|
702
+ history = manager.get_history(rule_id: name)
703
+ expect(history[:total_versions]).to eq(1)
704
+ expect(history[:active_version][:rule_id]).to eq(name)
705
+ end
706
+ end
707
+ end
708
+
709
+ describe "status validation" do
710
+ let(:rule_id) { "test_status_rule" }
711
+ let(:rule_content) do
712
+ {
713
+ version: "1.0",
714
+ ruleset: "test",
715
+ rules: [{ id: "test", if: { field: "x", op: "eq", value: 1 }, then: { decision: "approve", weight: 0.8, reason: "Test" } }]
716
+ }
717
+ end
718
+
719
+ it "rejects invalid status values when creating versions" do
720
+ expect do
721
+ adapter.create_version(
722
+ rule_id: rule_id,
723
+ content: rule_content,
724
+ metadata: { status: "banana" }
725
+ )
726
+ end.to raise_error(DecisionAgent::ValidationError, /Invalid status 'banana'/)
727
+
728
+ expect do
729
+ adapter.create_version(
730
+ rule_id: rule_id,
731
+ content: rule_content,
732
+ metadata: { status: "pending" }
733
+ )
734
+ end.to raise_error(DecisionAgent::ValidationError, /Invalid status 'pending'/)
735
+
736
+ expect do
737
+ adapter.create_version(
738
+ rule_id: rule_id,
739
+ content: rule_content,
740
+ metadata: { status: "deleted" }
741
+ )
742
+ end.to raise_error(DecisionAgent::ValidationError, /Invalid status 'deleted'/)
743
+ end
744
+
745
+ it "accepts valid status values" do
746
+ v1 = adapter.create_version(
747
+ rule_id: rule_id,
748
+ content: rule_content,
749
+ metadata: { status: "draft" }
750
+ )
751
+ expect(v1[:status]).to eq("draft")
752
+
753
+ v2 = adapter.create_version(
754
+ rule_id: "rule_002",
755
+ content: rule_content,
756
+ metadata: { status: "active" }
757
+ )
758
+ expect(v2[:status]).to eq("active")
759
+
760
+ v3 = adapter.create_version(
761
+ rule_id: "rule_003",
762
+ content: rule_content,
763
+ metadata: { status: "archived" }
764
+ )
765
+ expect(v3[:status]).to eq("archived")
766
+ end
767
+
768
+ it "uses default status 'active' when not provided" do
769
+ version = adapter.create_version(
770
+ rule_id: rule_id,
771
+ content: rule_content
772
+ )
773
+ expect(version[:status]).to eq("active")
774
+ end
775
+ end
776
+ end
777
+ end