lex-consent 0.2.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/Gemfile +11 -0
- data/lex-consent.gemspec +30 -0
- data/lib/legion/extensions/consent/actors/tier_evaluation.rb +41 -0
- data/lib/legion/extensions/consent/client.rb +23 -0
- data/lib/legion/extensions/consent/helpers/consent_map.rb +195 -0
- data/lib/legion/extensions/consent/helpers/tiers.rb +50 -0
- data/lib/legion/extensions/consent/local_migrations/20260316000010_create_consent_domains.rb +16 -0
- data/lib/legion/extensions/consent/runners/consent.rb +224 -0
- data/lib/legion/extensions/consent/version.rb +9 -0
- data/lib/legion/extensions/consent.rb +21 -0
- data/spec/legion/extensions/consent/actors/tier_evaluation_spec.rb +46 -0
- data/spec/legion/extensions/consent/client_spec.rb +33 -0
- data/spec/legion/extensions/consent/helpers/tiers_spec.rb +49 -0
- data/spec/legion/extensions/consent/runners/consent_spec.rb +224 -0
- data/spec/local_persistence_spec.rb +234 -0
- data/spec/spec_helper.rb +20 -0
- metadata +91 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8cf5e07bea7eb65742f83c8a32000eb93f0d90b2b35334f7d656ddd71d52824c
|
|
4
|
+
data.tar.gz: c23df3ed550f404a1dbfa6a811bff1bf96ac5bf09567d81ac9c9f161a20fbc8a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8448d8366e74202edf6755d830e09e729cc038f32394fe88046f552802a4628b9f53323c98af2b5fbd8739522500293efd720c0090cd0939d25ab0e57273f09b
|
|
7
|
+
data.tar.gz: 46da76635d0c2c55d4a62a80ec28dd417bac195f0ec9cd44603a6accf193c7f2966f469097537ee1b77955483190367562da123ff93990dd23b17efedc9edd00
|
data/Gemfile
ADDED
data/lex-consent.gemspec
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/consent/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-consent'
|
|
7
|
+
spec.version = Legion::Extensions::Consent::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Consent'
|
|
12
|
+
spec.description = 'Four-tier consent gradient with earned autonomy for brain-modeled agentic AI'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-consent'
|
|
14
|
+
spec.license = 'MIT'
|
|
15
|
+
spec.required_ruby_version = '>= 3.4'
|
|
16
|
+
|
|
17
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
18
|
+
spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-consent'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-consent'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-consent'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-consent/issues'
|
|
22
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
23
|
+
|
|
24
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
25
|
+
Dir.glob('{lib,spec}/**/*') + %w[lex-consent.gemspec Gemfile]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'sequel', '>= 5.70'
|
|
29
|
+
spec.add_development_dependency 'sqlite3', '>= 2.0'
|
|
30
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/actors/every'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Consent
|
|
8
|
+
module Actor
|
|
9
|
+
class TierEvaluation < Legion::Extensions::Actors::Every
|
|
10
|
+
def runner_class
|
|
11
|
+
Legion::Extensions::Consent::Runners::Consent
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def runner_function
|
|
15
|
+
'evaluate_and_apply_tiers'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def time
|
|
19
|
+
3600
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run_now?
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def use_runner?
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def check_subtask?
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def generate_task?
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/consent/helpers/tiers'
|
|
4
|
+
require 'legion/extensions/consent/helpers/consent_map'
|
|
5
|
+
require 'legion/extensions/consent/runners/consent'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module Extensions
|
|
9
|
+
module Consent
|
|
10
|
+
class Client
|
|
11
|
+
include Runners::Consent
|
|
12
|
+
|
|
13
|
+
def initialize(**)
|
|
14
|
+
@consent_map = Helpers::ConsentMap.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :consent_map
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Consent
|
|
8
|
+
module Helpers
|
|
9
|
+
class ConsentMap
|
|
10
|
+
APPROVAL_TIMEOUT = 259_200 # 72 hours
|
|
11
|
+
|
|
12
|
+
attr_reader :domains
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@domains = Hash.new do |h, k|
|
|
16
|
+
h[k] = {
|
|
17
|
+
tier: Tiers::DEFAULT_TIER,
|
|
18
|
+
success_count: 0,
|
|
19
|
+
failure_count: 0,
|
|
20
|
+
total_actions: 0,
|
|
21
|
+
last_changed_at: nil,
|
|
22
|
+
history: [],
|
|
23
|
+
pending_tier: nil,
|
|
24
|
+
pending_since: nil,
|
|
25
|
+
pending_requested_by: nil
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
load_from_local
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def get_tier(domain)
|
|
32
|
+
@domains[domain][:tier]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def set_tier(domain, tier)
|
|
36
|
+
return unless Tiers.valid_tier?(tier)
|
|
37
|
+
|
|
38
|
+
entry = @domains[domain]
|
|
39
|
+
old_tier = entry[:tier]
|
|
40
|
+
entry[:tier] = tier
|
|
41
|
+
entry[:last_changed_at] = Time.now.utc
|
|
42
|
+
entry[:history] << { from: old_tier, to: tier, at: Time.now.utc }
|
|
43
|
+
entry[:history].shift while entry[:history].size > 50
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def record_outcome(domain, success:)
|
|
47
|
+
entry = @domains[domain]
|
|
48
|
+
entry[:total_actions] += 1
|
|
49
|
+
if success
|
|
50
|
+
entry[:success_count] += 1
|
|
51
|
+
else
|
|
52
|
+
entry[:failure_count] += 1
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def success_rate(domain)
|
|
57
|
+
entry = @domains[domain]
|
|
58
|
+
return 0.0 if entry[:total_actions].zero?
|
|
59
|
+
|
|
60
|
+
entry[:success_count].to_f / entry[:total_actions]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def eligible_for_change?(domain)
|
|
64
|
+
entry = @domains[domain]
|
|
65
|
+
return false if entry[:total_actions] < Tiers::MIN_ACTIONS_TO_PROMOTE
|
|
66
|
+
|
|
67
|
+
if entry[:last_changed_at]
|
|
68
|
+
(Time.now.utc - entry[:last_changed_at]) >= Tiers::PROMOTION_COOLDOWN
|
|
69
|
+
else
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def evaluate_promotion(domain)
|
|
75
|
+
return :ineligible unless eligible_for_change?(domain)
|
|
76
|
+
|
|
77
|
+
rate = success_rate(domain)
|
|
78
|
+
current = get_tier(domain)
|
|
79
|
+
|
|
80
|
+
if rate >= Tiers::PROMOTION_THRESHOLD
|
|
81
|
+
promoted = Tiers.promote(current)
|
|
82
|
+
return :already_max if promoted == current
|
|
83
|
+
|
|
84
|
+
:promote
|
|
85
|
+
elsif rate < Tiers::DEMOTION_THRESHOLD
|
|
86
|
+
demoted = Tiers.demote(current)
|
|
87
|
+
return :already_min if demoted == current
|
|
88
|
+
|
|
89
|
+
:demote
|
|
90
|
+
else
|
|
91
|
+
:maintain
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def domain_count
|
|
96
|
+
@domains.size
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def to_h
|
|
100
|
+
@domains.transform_values do |v|
|
|
101
|
+
{ tier: v[:tier], success_rate: success_rate_from(v), total_actions: v[:total_actions],
|
|
102
|
+
pending_tier: v[:pending_tier], pending_since: v[:pending_since] }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def set_pending(domain, proposed_tier:, requested_by: 'system')
|
|
107
|
+
entry = @domains[domain]
|
|
108
|
+
entry[:pending_tier] = proposed_tier
|
|
109
|
+
entry[:pending_since] = Time.now
|
|
110
|
+
entry[:pending_requested_by] = requested_by
|
|
111
|
+
entry
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def clear_pending(domain)
|
|
115
|
+
entry = @domains[domain]
|
|
116
|
+
entry[:pending_tier] = nil
|
|
117
|
+
entry[:pending_since] = nil
|
|
118
|
+
entry[:pending_requested_by] = nil
|
|
119
|
+
entry
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def pending?(domain)
|
|
123
|
+
!@domains[domain][:pending_tier].nil?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def pending_expired?(domain, timeout: APPROVAL_TIMEOUT)
|
|
127
|
+
entry = @domains[domain]
|
|
128
|
+
return false unless entry[:pending_since]
|
|
129
|
+
|
|
130
|
+
Time.now - entry[:pending_since] > timeout
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def save_to_local
|
|
134
|
+
return unless defined?(Legion::Data::Local) && Legion::Data::Local.connected?
|
|
135
|
+
|
|
136
|
+
dataset = Legion::Data::Local.connection[:consent_domains]
|
|
137
|
+
@domains.each do |domain_key, entry|
|
|
138
|
+
row = {
|
|
139
|
+
domain_key: domain_key,
|
|
140
|
+
tier: entry[:tier].to_s,
|
|
141
|
+
success_count: entry[:success_count],
|
|
142
|
+
failure_count: entry[:failure_count],
|
|
143
|
+
total_actions: entry[:total_actions],
|
|
144
|
+
last_changed_at: entry[:last_changed_at],
|
|
145
|
+
history: ::JSON.generate(entry[:history].map { |h| h.transform_values(&:to_s) })
|
|
146
|
+
}
|
|
147
|
+
existing = dataset.where(domain_key: domain_key).first
|
|
148
|
+
if existing
|
|
149
|
+
dataset.where(domain_key: domain_key).update(row.except(:domain_key))
|
|
150
|
+
else
|
|
151
|
+
dataset.insert(row)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
rescue StandardError => e
|
|
155
|
+
Legion::Logging.warn "[consent] save_to_local failed: #{e.message}" if defined?(Legion::Logging)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def load_from_local
|
|
159
|
+
return unless defined?(Legion::Data::Local) && Legion::Data::Local.connected?
|
|
160
|
+
|
|
161
|
+
Legion::Data::Local.connection[:consent_domains].each do |row|
|
|
162
|
+
key = row[:domain_key]
|
|
163
|
+
history = begin
|
|
164
|
+
::JSON.parse(row[:history] || '[]', symbolize_names: false).map do |h|
|
|
165
|
+
{ from: h['from'].to_sym, to: h['to'].to_sym, at: h['at'] }
|
|
166
|
+
end
|
|
167
|
+
rescue StandardError
|
|
168
|
+
[]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
@domains[key] = {
|
|
172
|
+
tier: row[:tier].to_sym,
|
|
173
|
+
success_count: row[:success_count].to_i,
|
|
174
|
+
failure_count: row[:failure_count].to_i,
|
|
175
|
+
total_actions: row[:total_actions].to_i,
|
|
176
|
+
last_changed_at: row[:last_changed_at],
|
|
177
|
+
history: history
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
Legion::Logging.warn "[consent] load_from_local failed: #{e.message}" if defined?(Legion::Logging)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
def success_rate_from(entry)
|
|
187
|
+
return 0.0 if entry[:total_actions].zero?
|
|
188
|
+
|
|
189
|
+
entry[:success_count].to_f / entry[:total_actions]
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Consent
|
|
6
|
+
module Helpers
|
|
7
|
+
module Tiers
|
|
8
|
+
# Four consent tiers (spec: consent-gradient-spec.md)
|
|
9
|
+
TIERS = %i[autonomous act_notify consult human_only].freeze
|
|
10
|
+
|
|
11
|
+
# Default starting tier for new domains
|
|
12
|
+
DEFAULT_TIER = :consult
|
|
13
|
+
|
|
14
|
+
# Thresholds for tier promotion/demotion
|
|
15
|
+
PROMOTION_THRESHOLD = 0.8 # success rate needed to promote
|
|
16
|
+
DEMOTION_THRESHOLD = 0.5 # success rate below which demotion occurs
|
|
17
|
+
MIN_ACTIONS_TO_PROMOTE = 10 # minimum actions before tier change
|
|
18
|
+
PROMOTION_COOLDOWN = 86_400 # seconds between tier changes (24h)
|
|
19
|
+
|
|
20
|
+
# Tier ordering (lower index = more autonomy)
|
|
21
|
+
TIER_ORDER = { autonomous: 0, act_notify: 1, consult: 2, human_only: 3 }.freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def valid_tier?(tier)
|
|
26
|
+
TIERS.include?(tier)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def more_autonomous?(tier_a, tier_b)
|
|
30
|
+
TIER_ORDER.fetch(tier_a, 99) < TIER_ORDER.fetch(tier_b, 99)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def promote(current_tier)
|
|
34
|
+
idx = TIER_ORDER.fetch(current_tier, 2)
|
|
35
|
+
return current_tier if idx.zero?
|
|
36
|
+
|
|
37
|
+
TIER_ORDER.key(idx - 1) || current_tier
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def demote(current_tier)
|
|
41
|
+
idx = TIER_ORDER.fetch(current_tier, 2)
|
|
42
|
+
return current_tier if idx >= 3
|
|
43
|
+
|
|
44
|
+
TIER_ORDER.key(idx + 1) || current_tier
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Sequel.migration do
|
|
4
|
+
change do
|
|
5
|
+
create_table(:consent_domains) do
|
|
6
|
+
primary_key :id
|
|
7
|
+
String :domain_key, null: false, unique: true, index: true
|
|
8
|
+
String :tier, null: false, default: 'consult'
|
|
9
|
+
Integer :success_count, default: 0
|
|
10
|
+
Integer :failure_count, default: 0
|
|
11
|
+
Integer :total_actions, default: 0
|
|
12
|
+
DateTime :last_changed_at
|
|
13
|
+
String :history, text: true
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Consent
|
|
6
|
+
module Runners
|
|
7
|
+
module Consent
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def check_consent(domain:, _action_type: :general, **)
|
|
12
|
+
tier = consent_map.get_tier(domain)
|
|
13
|
+
Legion::Logging.debug "[consent] check: domain=#{domain} tier=#{tier} allowed=#{tier == :autonomous}"
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
domain: domain,
|
|
17
|
+
tier: tier,
|
|
18
|
+
allowed: tier == :autonomous,
|
|
19
|
+
needs_notify: tier == :act_notify,
|
|
20
|
+
needs_consult: tier == :consult,
|
|
21
|
+
human_only: tier == :human_only
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def record_action(domain:, success:, **)
|
|
26
|
+
consent_map.record_outcome(domain, success: success)
|
|
27
|
+
rate = consent_map.success_rate(domain)
|
|
28
|
+
total = consent_map.domains[domain][:total_actions]
|
|
29
|
+
Legion::Logging.info "[consent] action recorded: domain=#{domain} success=#{success} rate=#{rate.round(2)} total=#{total}"
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
domain: domain,
|
|
33
|
+
success: success,
|
|
34
|
+
success_rate: rate,
|
|
35
|
+
total: total
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def evaluate_tier_change(domain:, **)
|
|
40
|
+
recommendation = consent_map.evaluate_promotion(domain)
|
|
41
|
+
current = consent_map.get_tier(domain)
|
|
42
|
+
|
|
43
|
+
result = {
|
|
44
|
+
domain: domain,
|
|
45
|
+
current_tier: current,
|
|
46
|
+
recommendation: recommendation,
|
|
47
|
+
success_rate: consent_map.success_rate(domain)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
case recommendation
|
|
51
|
+
when :promote
|
|
52
|
+
result[:proposed_tier] = Helpers::Tiers.promote(current)
|
|
53
|
+
Legion::Logging.info "[consent] tier change: domain=#{domain} recommend=promote from=#{current} to=#{result[:proposed_tier]}"
|
|
54
|
+
when :demote
|
|
55
|
+
result[:proposed_tier] = Helpers::Tiers.demote(current)
|
|
56
|
+
Legion::Logging.warn "[consent] tier change: domain=#{domain} recommend=demote from=#{current} to=#{result[:proposed_tier]}"
|
|
57
|
+
else
|
|
58
|
+
Legion::Logging.debug "[consent] tier eval: domain=#{domain} current=#{current} recommendation=#{recommendation}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
result
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def apply_tier_change(domain:, new_tier:, **)
|
|
65
|
+
return { error: :invalid_tier, valid_tiers: Helpers::Tiers::TIERS } unless Helpers::Tiers.valid_tier?(new_tier)
|
|
66
|
+
|
|
67
|
+
old_tier = consent_map.get_tier(domain)
|
|
68
|
+
consent_map.set_tier(domain, new_tier)
|
|
69
|
+
changed = old_tier != new_tier
|
|
70
|
+
Legion::Logging.info "[consent] tier applied: domain=#{domain} old=#{old_tier} new=#{new_tier} changed=#{changed}"
|
|
71
|
+
{ domain: domain, old_tier: old_tier, new_tier: new_tier, changed: changed }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def evaluate_all_tiers(**)
|
|
75
|
+
promotions = []
|
|
76
|
+
demotions = []
|
|
77
|
+
|
|
78
|
+
consent_map.domains.each_key do |domain|
|
|
79
|
+
result = consent_map.evaluate_promotion(domain)
|
|
80
|
+
promotions << domain if result == :promote
|
|
81
|
+
demotions << domain if result == :demote
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
evaluated = consent_map.domain_count
|
|
85
|
+
Legion::Logging.debug "[consent] tier evaluation sweep: domains=#{evaluated} " \
|
|
86
|
+
"promotions=#{promotions.size} demotions=#{demotions.size}"
|
|
87
|
+
|
|
88
|
+
{ evaluated: evaluated, promotions: promotions, demotions: demotions }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def request_autonomous_approval(domain:, **)
|
|
92
|
+
map = consent_map
|
|
93
|
+
current_tier = map.get_tier(domain)
|
|
94
|
+
|
|
95
|
+
return { requested: false, error: 'already_autonomous' } if current_tier == :autonomous
|
|
96
|
+
return { requested: false, error: 'already_pending' } if map.pending?(domain)
|
|
97
|
+
|
|
98
|
+
map.set_pending(domain, proposed_tier: :autonomous, requested_by: 'tier_evaluation')
|
|
99
|
+
|
|
100
|
+
if defined?(Legion::Events)
|
|
101
|
+
rate = map.success_rate(domain)
|
|
102
|
+
total = map.domains[domain][:total_actions]
|
|
103
|
+
Legion::Events.emit('consent.approval_required', {
|
|
104
|
+
domain: domain,
|
|
105
|
+
current_tier: current_tier,
|
|
106
|
+
proposed_tier: :autonomous,
|
|
107
|
+
success_rate: rate,
|
|
108
|
+
total_actions: total,
|
|
109
|
+
requested_at: Time.now.utc
|
|
110
|
+
})
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
{ requested: true, domain: domain, current_tier: current_tier, proposed_tier: :autonomous }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def approve_promotion(domain:, approved_by:, **)
|
|
117
|
+
map = consent_map
|
|
118
|
+
return { approved: false, error: 'no_pending_approval' } unless map.pending?(domain)
|
|
119
|
+
|
|
120
|
+
pending_tier = map.domains[domain][:pending_tier]
|
|
121
|
+
old_tier = map.get_tier(domain)
|
|
122
|
+
map.clear_pending(domain)
|
|
123
|
+
map.set_tier(domain, pending_tier)
|
|
124
|
+
|
|
125
|
+
if defined?(Legion::Events)
|
|
126
|
+
Legion::Events.emit('consent.promotion_approved', {
|
|
127
|
+
domain: domain, old_tier: old_tier, new_tier: pending_tier,
|
|
128
|
+
approved_by: approved_by, at: Time.now.utc
|
|
129
|
+
})
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
{ approved: true, domain: domain, old_tier: old_tier, new_tier: pending_tier, approved_by: approved_by }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def reject_promotion(domain:, rejected_by:, reason: nil, **)
|
|
136
|
+
map = consent_map
|
|
137
|
+
return { rejected: false, error: 'no_pending_approval' } unless map.pending?(domain)
|
|
138
|
+
|
|
139
|
+
map.clear_pending(domain)
|
|
140
|
+
|
|
141
|
+
if defined?(Legion::Events)
|
|
142
|
+
Legion::Events.emit('consent.promotion_rejected', {
|
|
143
|
+
domain: domain, rejected_by: rejected_by, reason: reason, at: Time.now.utc
|
|
144
|
+
})
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
{ rejected: true, domain: domain, rejected_by: rejected_by, reason: reason }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def expire_pending_approvals(timeout: Helpers::ConsentMap::APPROVAL_TIMEOUT, **)
|
|
151
|
+
map = consent_map
|
|
152
|
+
expired = 0
|
|
153
|
+
|
|
154
|
+
map.domains.each_key do |domain|
|
|
155
|
+
next unless map.pending?(domain)
|
|
156
|
+
next unless map.pending_expired?(domain, timeout: timeout)
|
|
157
|
+
|
|
158
|
+
map.clear_pending(domain)
|
|
159
|
+
expired += 1
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
{ expired: expired }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def evaluate_and_apply_tiers(**)
|
|
166
|
+
candidates = evaluate_all_tiers
|
|
167
|
+
applied_promotions = 0
|
|
168
|
+
applied_demotions = 0
|
|
169
|
+
approval_requests = 0
|
|
170
|
+
|
|
171
|
+
Array(candidates[:promotions]).each do |domain|
|
|
172
|
+
current = consent_map.get_tier(domain)
|
|
173
|
+
proposed = Helpers::Tiers.promote(current)
|
|
174
|
+
|
|
175
|
+
if proposed == :autonomous
|
|
176
|
+
result = request_autonomous_approval(domain: domain)
|
|
177
|
+
approval_requests += 1 if result[:requested]
|
|
178
|
+
else
|
|
179
|
+
apply_tier_change(domain: domain, new_tier: proposed)
|
|
180
|
+
applied_promotions += 1
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
Array(candidates[:demotions]).each do |domain|
|
|
185
|
+
current = consent_map.get_tier(domain)
|
|
186
|
+
proposed = Helpers::Tiers.demote(current)
|
|
187
|
+
apply_tier_change(domain: domain, new_tier: proposed)
|
|
188
|
+
applied_demotions += 1
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
expired = expire_pending_approvals
|
|
192
|
+
|
|
193
|
+
{ evaluated: candidates[:evaluated], applied_promotions: applied_promotions,
|
|
194
|
+
applied_demotions: applied_demotions, approval_requests: approval_requests,
|
|
195
|
+
expired_approvals: expired[:expired] }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def consent_status(domain: nil, **)
|
|
199
|
+
if domain
|
|
200
|
+
entry = consent_map.domains[domain]
|
|
201
|
+
Legion::Logging.debug "[consent] status: domain=#{domain} tier=#{entry[:tier]} total=#{entry[:total_actions]}"
|
|
202
|
+
{
|
|
203
|
+
domain: domain,
|
|
204
|
+
tier: entry[:tier],
|
|
205
|
+
success_rate: consent_map.success_rate(domain),
|
|
206
|
+
total: entry[:total_actions],
|
|
207
|
+
eligible: consent_map.eligible_for_change?(domain)
|
|
208
|
+
}
|
|
209
|
+
else
|
|
210
|
+
Legion::Logging.debug "[consent] status: domains=#{consent_map.domain_count}"
|
|
211
|
+
{ domains: consent_map.to_h, count: consent_map.domain_count }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
def consent_map
|
|
218
|
+
@consent_map ||= Helpers::ConsentMap.new
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/consent/version'
|
|
4
|
+
require 'legion/extensions/consent/helpers/tiers'
|
|
5
|
+
require 'legion/extensions/consent/helpers/consent_map'
|
|
6
|
+
require 'legion/extensions/consent/runners/consent'
|
|
7
|
+
|
|
8
|
+
module Legion
|
|
9
|
+
module Extensions
|
|
10
|
+
module Consent
|
|
11
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
if defined?(Legion::Data::Local)
|
|
17
|
+
Legion::Data::Local.register_migrations(
|
|
18
|
+
name: :consent,
|
|
19
|
+
path: File.join(__dir__, 'consent', 'local_migrations')
|
|
20
|
+
)
|
|
21
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Stub the base class before loading the actor
|
|
4
|
+
module Legion
|
|
5
|
+
module Extensions
|
|
6
|
+
module Actors
|
|
7
|
+
class Every; end # rubocop:disable Lint/EmptyClass
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
$LOADED_FEATURES << 'legion/extensions/actors/every'
|
|
13
|
+
|
|
14
|
+
require_relative '../../../../../lib/legion/extensions/consent/actors/tier_evaluation'
|
|
15
|
+
|
|
16
|
+
RSpec.describe Legion::Extensions::Consent::Actor::TierEvaluation do
|
|
17
|
+
subject(:actor) { described_class.new }
|
|
18
|
+
|
|
19
|
+
describe '#runner_class' do
|
|
20
|
+
it { expect(actor.runner_class).to eq Legion::Extensions::Consent::Runners::Consent }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe '#runner_function' do
|
|
24
|
+
it { expect(actor.runner_function).to eq 'evaluate_and_apply_tiers' }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
describe '#time' do
|
|
28
|
+
it { expect(actor.time).to eq 3600 }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe '#run_now?' do
|
|
32
|
+
it { expect(actor.run_now?).to be false }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe '#use_runner?' do
|
|
36
|
+
it { expect(actor.use_runner?).to be false }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe '#check_subtask?' do
|
|
40
|
+
it { expect(actor.check_subtask?).to be false }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe '#generate_task?' do
|
|
44
|
+
it { expect(actor.generate_task?).to be false }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/consent/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Consent::Client do
|
|
6
|
+
let(:client) { described_class.new }
|
|
7
|
+
|
|
8
|
+
it 'responds to consent runner methods' do
|
|
9
|
+
expect(client).to respond_to(:check_consent)
|
|
10
|
+
expect(client).to respond_to(:record_action)
|
|
11
|
+
expect(client).to respond_to(:evaluate_tier_change)
|
|
12
|
+
expect(client).to respond_to(:apply_tier_change)
|
|
13
|
+
expect(client).to respond_to(:consent_status)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'round-trips earned autonomy lifecycle' do
|
|
17
|
+
# Start with consult
|
|
18
|
+
check = client.check_consent(domain: 'scheduling')
|
|
19
|
+
expect(check[:tier]).to eq(:consult)
|
|
20
|
+
|
|
21
|
+
# Build track record
|
|
22
|
+
12.times { client.record_action(domain: 'scheduling', success: true) }
|
|
23
|
+
|
|
24
|
+
# Evaluate
|
|
25
|
+
eval_result = client.evaluate_tier_change(domain: 'scheduling')
|
|
26
|
+
expect(eval_result[:recommendation]).to eq(:promote)
|
|
27
|
+
|
|
28
|
+
# Apply
|
|
29
|
+
client.apply_tier_change(domain: 'scheduling', new_tier: eval_result[:proposed_tier])
|
|
30
|
+
check = client.check_consent(domain: 'scheduling')
|
|
31
|
+
expect(check[:tier]).to eq(:act_notify)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Consent::Helpers::Tiers do
|
|
4
|
+
describe '.valid_tier?' do
|
|
5
|
+
it 'accepts valid tiers' do
|
|
6
|
+
%i[autonomous act_notify consult human_only].each do |tier|
|
|
7
|
+
expect(described_class.valid_tier?(tier)).to be true
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'rejects invalid tiers' do
|
|
12
|
+
expect(described_class.valid_tier?(:invalid)).to be false
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe '.promote' do
|
|
17
|
+
it 'promotes consult to act_notify' do
|
|
18
|
+
expect(described_class.promote(:consult)).to eq(:act_notify)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'promotes act_notify to autonomous' do
|
|
22
|
+
expect(described_class.promote(:act_notify)).to eq(:autonomous)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'cannot promote beyond autonomous' do
|
|
26
|
+
expect(described_class.promote(:autonomous)).to eq(:autonomous)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe '.demote' do
|
|
31
|
+
it 'demotes consult to human_only' do
|
|
32
|
+
expect(described_class.demote(:consult)).to eq(:human_only)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'cannot demote beyond human_only' do
|
|
36
|
+
expect(described_class.demote(:human_only)).to eq(:human_only)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe '.more_autonomous?' do
|
|
41
|
+
it 'returns true when first tier is more autonomous' do
|
|
42
|
+
expect(described_class.more_autonomous?(:autonomous, :consult)).to be true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'returns false when second tier is more autonomous' do
|
|
46
|
+
expect(described_class.more_autonomous?(:human_only, :consult)).to be false
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/consent/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Consent::Runners::Consent do
|
|
6
|
+
let(:client) { Legion::Extensions::Consent::Client.new }
|
|
7
|
+
|
|
8
|
+
describe '#check_consent' do
|
|
9
|
+
it 'returns default tier for new domain' do
|
|
10
|
+
result = client.check_consent(domain: 'email')
|
|
11
|
+
expect(result[:tier]).to eq(:consult)
|
|
12
|
+
expect(result[:needs_consult]).to be true
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe '#record_action' do
|
|
17
|
+
it 'records action outcome' do
|
|
18
|
+
result = client.record_action(domain: 'email', success: true)
|
|
19
|
+
expect(result[:success]).to be true
|
|
20
|
+
expect(result[:total]).to eq(1)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'tracks success rate' do
|
|
24
|
+
3.times { client.record_action(domain: 'email', success: true) }
|
|
25
|
+
client.record_action(domain: 'email', success: false)
|
|
26
|
+
result = client.record_action(domain: 'email', success: true)
|
|
27
|
+
expect(result[:success_rate]).to eq(0.8)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe '#evaluate_tier_change' do
|
|
32
|
+
it 'returns ineligible when not enough actions' do
|
|
33
|
+
result = client.evaluate_tier_change(domain: 'email')
|
|
34
|
+
expect(result[:recommendation]).to eq(:ineligible)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'recommends promotion with high success rate' do
|
|
38
|
+
15.times { client.record_action(domain: 'calendar', success: true) }
|
|
39
|
+
result = client.evaluate_tier_change(domain: 'calendar')
|
|
40
|
+
expect(result[:recommendation]).to eq(:promote)
|
|
41
|
+
expect(result[:proposed_tier]).to eq(:act_notify)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'recommends demotion with low success rate' do
|
|
45
|
+
4.times { client.record_action(domain: 'risky', success: true) }
|
|
46
|
+
8.times { client.record_action(domain: 'risky', success: false) }
|
|
47
|
+
result = client.evaluate_tier_change(domain: 'risky')
|
|
48
|
+
expect(result[:recommendation]).to eq(:demote)
|
|
49
|
+
expect(result[:proposed_tier]).to eq(:human_only)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
describe '#apply_tier_change' do
|
|
54
|
+
it 'changes tier' do
|
|
55
|
+
result = client.apply_tier_change(domain: 'email', new_tier: :autonomous)
|
|
56
|
+
expect(result[:new_tier]).to eq(:autonomous)
|
|
57
|
+
expect(result[:changed]).to be true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'rejects invalid tier' do
|
|
61
|
+
result = client.apply_tier_change(domain: 'email', new_tier: :invalid)
|
|
62
|
+
expect(result[:error]).to eq(:invalid_tier)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
describe '#evaluate_all_tiers' do
|
|
67
|
+
it 'returns zero evaluated for empty consent map' do
|
|
68
|
+
result = client.evaluate_all_tiers
|
|
69
|
+
expect(result[:evaluated]).to eq(0)
|
|
70
|
+
expect(result[:promotions]).to eq([])
|
|
71
|
+
expect(result[:demotions]).to eq([])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'includes promoted domains in promotions list' do
|
|
75
|
+
15.times { client.record_action(domain: 'calendar', success: true) }
|
|
76
|
+
result = client.evaluate_all_tiers
|
|
77
|
+
expect(result[:promotions]).to include('calendar')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'includes demoted domains in demotions list' do
|
|
81
|
+
4.times { client.record_action(domain: 'risky', success: true) }
|
|
82
|
+
8.times { client.record_action(domain: 'risky', success: false) }
|
|
83
|
+
result = client.evaluate_all_tiers
|
|
84
|
+
expect(result[:demotions]).to include('risky')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'returns evaluated count matching number of known domains' do
|
|
88
|
+
client.record_action(domain: 'email', success: true)
|
|
89
|
+
client.record_action(domain: 'calendar', success: true)
|
|
90
|
+
result = client.evaluate_all_tiers
|
|
91
|
+
expect(result[:evaluated]).to eq(2)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'does not include ineligible domains in promotions or demotions' do
|
|
95
|
+
client.record_action(domain: 'email', success: true)
|
|
96
|
+
result = client.evaluate_all_tiers
|
|
97
|
+
expect(result[:promotions]).not_to include('email')
|
|
98
|
+
expect(result[:demotions]).not_to include('email')
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
describe '#request_autonomous_approval' do
|
|
103
|
+
it 'sets pending state for non-autonomous domain' do
|
|
104
|
+
client.record_action(domain: 'email', success: true)
|
|
105
|
+
result = client.request_autonomous_approval(domain: 'email')
|
|
106
|
+
expect(result[:requested]).to be true
|
|
107
|
+
expect(result[:proposed_tier]).to eq(:autonomous)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'rejects if already autonomous' do
|
|
111
|
+
client.apply_tier_change(domain: 'email', new_tier: :autonomous)
|
|
112
|
+
result = client.request_autonomous_approval(domain: 'email')
|
|
113
|
+
expect(result[:requested]).to be false
|
|
114
|
+
expect(result[:error]).to eq('already_autonomous')
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'rejects if already pending' do
|
|
118
|
+
client.request_autonomous_approval(domain: 'email')
|
|
119
|
+
result = client.request_autonomous_approval(domain: 'email')
|
|
120
|
+
expect(result[:requested]).to be false
|
|
121
|
+
expect(result[:error]).to eq('already_pending')
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
describe '#approve_promotion' do
|
|
126
|
+
it 'applies pending tier change' do
|
|
127
|
+
client.request_autonomous_approval(domain: 'email')
|
|
128
|
+
result = client.approve_promotion(domain: 'email', approved_by: 'admin')
|
|
129
|
+
expect(result[:approved]).to be true
|
|
130
|
+
expect(result[:new_tier]).to eq(:autonomous)
|
|
131
|
+
expect(result[:approved_by]).to eq('admin')
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'rejects when no pending approval' do
|
|
135
|
+
result = client.approve_promotion(domain: 'email', approved_by: 'admin')
|
|
136
|
+
expect(result[:approved]).to be false
|
|
137
|
+
expect(result[:error]).to eq('no_pending_approval')
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
describe '#reject_promotion' do
|
|
142
|
+
it 'clears pending state' do
|
|
143
|
+
client.request_autonomous_approval(domain: 'email')
|
|
144
|
+
result = client.reject_promotion(domain: 'email', rejected_by: 'admin', reason: 'not ready')
|
|
145
|
+
expect(result[:rejected]).to be true
|
|
146
|
+
expect(result[:reason]).to eq('not ready')
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'rejects when no pending approval' do
|
|
150
|
+
result = client.reject_promotion(domain: 'email', rejected_by: 'admin')
|
|
151
|
+
expect(result[:rejected]).to be false
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
describe '#expire_pending_approvals' do
|
|
156
|
+
it 'expires stale approvals' do
|
|
157
|
+
client.request_autonomous_approval(domain: 'email')
|
|
158
|
+
result = client.expire_pending_approvals(timeout: 0)
|
|
159
|
+
expect(result[:expired]).to eq(1)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'does not expire fresh approvals' do
|
|
163
|
+
client.request_autonomous_approval(domain: 'email')
|
|
164
|
+
result = client.expire_pending_approvals(timeout: 999_999)
|
|
165
|
+
expect(result[:expired]).to eq(0)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
describe '#evaluate_and_apply_tiers' do
|
|
170
|
+
it 'returns summary with zero counts for empty map' do
|
|
171
|
+
result = client.evaluate_and_apply_tiers
|
|
172
|
+
expect(result[:evaluated]).to eq(0)
|
|
173
|
+
expect(result[:applied_promotions]).to eq(0)
|
|
174
|
+
expect(result[:applied_demotions]).to eq(0)
|
|
175
|
+
expect(result[:approval_requests]).to eq(0)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it 'auto-applies non-autonomous promotions' do
|
|
179
|
+
15.times { client.record_action(domain: 'calendar', success: true) }
|
|
180
|
+
result = client.evaluate_and_apply_tiers
|
|
181
|
+
expect(result[:applied_promotions]).to eq(1)
|
|
182
|
+
status = client.consent_status(domain: 'calendar')
|
|
183
|
+
expect(status[:tier]).to eq(:act_notify)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it 'requests approval for autonomous promotions instead of auto-applying' do
|
|
187
|
+
client.apply_tier_change(domain: 'calendar', new_tier: :act_notify)
|
|
188
|
+
# Backdate last_changed_at to bypass the 24h cooldown
|
|
189
|
+
map = client.send(:consent_map)
|
|
190
|
+
map.domains['calendar'][:last_changed_at] = Time.now.utc - 100_000
|
|
191
|
+
15.times { client.record_action(domain: 'calendar', success: true) }
|
|
192
|
+
result = client.evaluate_and_apply_tiers
|
|
193
|
+
expect(result[:approval_requests]).to eq(1)
|
|
194
|
+
expect(result[:applied_promotions]).to eq(0)
|
|
195
|
+
status = client.consent_status(domain: 'calendar')
|
|
196
|
+
expect(status[:tier]).to eq(:act_notify)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it 'auto-applies demotions' do
|
|
200
|
+
4.times { client.record_action(domain: 'risky', success: true) }
|
|
201
|
+
8.times { client.record_action(domain: 'risky', success: false) }
|
|
202
|
+
result = client.evaluate_and_apply_tiers
|
|
203
|
+
expect(result[:applied_demotions]).to eq(1)
|
|
204
|
+
status = client.consent_status(domain: 'risky')
|
|
205
|
+
expect(status[:tier]).to eq(:human_only)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
describe '#consent_status' do
|
|
210
|
+
it 'returns domain-specific status' do
|
|
211
|
+
client.record_action(domain: 'email', success: true)
|
|
212
|
+
result = client.consent_status(domain: 'email')
|
|
213
|
+
expect(result[:tier]).to eq(:consult)
|
|
214
|
+
expect(result[:total]).to eq(1)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'returns all domains when no domain specified' do
|
|
218
|
+
client.record_action(domain: 'email', success: true)
|
|
219
|
+
client.record_action(domain: 'calendar', success: true)
|
|
220
|
+
result = client.consent_status
|
|
221
|
+
expect(result[:count]).to eq(2)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'sequel'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'tmpdir'
|
|
7
|
+
|
|
8
|
+
RSpec.describe 'lex-consent local SQLite persistence' do
|
|
9
|
+
let(:db_path) { File.join(Dir.tmpdir, "consent_test_#{Process.pid}_#{rand(9999)}.db") }
|
|
10
|
+
let(:db) { Sequel.sqlite(db_path) }
|
|
11
|
+
|
|
12
|
+
before do
|
|
13
|
+
# Create the schema in the temp DB
|
|
14
|
+
db.create_table(:consent_domains) do
|
|
15
|
+
primary_key :id
|
|
16
|
+
String :domain_key, null: false, unique: true
|
|
17
|
+
String :tier, null: false, default: 'consult'
|
|
18
|
+
Integer :success_count, default: 0
|
|
19
|
+
Integer :failure_count, default: 0
|
|
20
|
+
Integer :total_actions, default: 0
|
|
21
|
+
DateTime :last_changed_at
|
|
22
|
+
String :history, text: true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Stub Legion::Data::Local to use our temp DB
|
|
26
|
+
stub_const('Legion::Data::Local', Module.new do
|
|
27
|
+
def self.connected?
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.connection
|
|
32
|
+
@_connection
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self._set_connection(conn)
|
|
36
|
+
@_connection = conn
|
|
37
|
+
end
|
|
38
|
+
end)
|
|
39
|
+
|
|
40
|
+
Legion::Data::Local._set_connection(db)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
after do
|
|
44
|
+
db.disconnect
|
|
45
|
+
FileUtils.rm_f(db_path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe 'save_to_local' do
|
|
49
|
+
it 'writes domain state to the database' do
|
|
50
|
+
map = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
51
|
+
map.record_outcome('email', success: true)
|
|
52
|
+
map.record_outcome('email', success: true)
|
|
53
|
+
map.save_to_local
|
|
54
|
+
|
|
55
|
+
row = db[:consent_domains].where(domain_key: 'email').first
|
|
56
|
+
expect(row).not_to be_nil
|
|
57
|
+
expect(row[:domain_key]).to eq('email')
|
|
58
|
+
expect(row[:tier]).to eq('consult')
|
|
59
|
+
expect(row[:success_count]).to eq(2)
|
|
60
|
+
expect(row[:total_actions]).to eq(2)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'updates an existing row on second save' do
|
|
64
|
+
map = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
65
|
+
map.record_outcome('email', success: true)
|
|
66
|
+
map.save_to_local
|
|
67
|
+
|
|
68
|
+
map.record_outcome('email', success: false)
|
|
69
|
+
map.save_to_local
|
|
70
|
+
|
|
71
|
+
rows = db[:consent_domains].where(domain_key: 'email').all
|
|
72
|
+
expect(rows.size).to eq(1)
|
|
73
|
+
expect(rows.first[:failure_count]).to eq(1)
|
|
74
|
+
expect(rows.first[:total_actions]).to eq(2)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'serializes tier as a string' do
|
|
78
|
+
map = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
79
|
+
map.set_tier('scheduling', :autonomous)
|
|
80
|
+
map.save_to_local
|
|
81
|
+
|
|
82
|
+
row = db[:consent_domains].where(domain_key: 'scheduling').first
|
|
83
|
+
expect(row[:tier]).to eq('autonomous')
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'serializes history as JSON' do
|
|
87
|
+
map = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
88
|
+
map.set_tier('scheduling', :autonomous)
|
|
89
|
+
map.save_to_local
|
|
90
|
+
|
|
91
|
+
row = db[:consent_domains].where(domain_key: 'scheduling').first
|
|
92
|
+
parsed = JSON.parse(row[:history])
|
|
93
|
+
expect(parsed).to be_an(Array)
|
|
94
|
+
expect(parsed.first['from']).to eq('consult')
|
|
95
|
+
expect(parsed.first['to']).to eq('autonomous')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'persists multiple domains' do
|
|
99
|
+
map = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
100
|
+
map.record_outcome('email', success: true)
|
|
101
|
+
map.record_outcome('calendar', success: false)
|
|
102
|
+
map.save_to_local
|
|
103
|
+
|
|
104
|
+
expect(db[:consent_domains].count).to eq(2)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
describe 'load_from_local' do
|
|
109
|
+
it 'restores domain state from the database' do
|
|
110
|
+
db[:consent_domains].insert(
|
|
111
|
+
domain_key: 'email',
|
|
112
|
+
tier: 'act_notify',
|
|
113
|
+
success_count: 5,
|
|
114
|
+
failure_count: 1,
|
|
115
|
+
total_actions: 6,
|
|
116
|
+
history: '[]'
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
map = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
120
|
+
expect(map.get_tier('email')).to eq(:act_notify)
|
|
121
|
+
expect(map.domains['email'][:success_count]).to eq(5)
|
|
122
|
+
expect(map.domains['email'][:total_actions]).to eq(6)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'restores history as an array of hashes with symbol keys' do
|
|
126
|
+
history_json = JSON.generate([{ 'from' => 'consult', 'to' => 'act_notify', 'at' => Time.now.utc.to_s }])
|
|
127
|
+
db[:consent_domains].insert(
|
|
128
|
+
domain_key: 'scheduling',
|
|
129
|
+
tier: 'act_notify',
|
|
130
|
+
success_count: 10,
|
|
131
|
+
failure_count: 0,
|
|
132
|
+
total_actions: 10,
|
|
133
|
+
history: history_json
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
map = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
137
|
+
history = map.domains['scheduling'][:history]
|
|
138
|
+
expect(history).to be_an(Array)
|
|
139
|
+
expect(history.first[:from]).to eq(:consult)
|
|
140
|
+
expect(history.first[:to]).to eq(:act_notify)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it 'handles empty history JSON gracefully' do
|
|
144
|
+
db[:consent_domains].insert(
|
|
145
|
+
domain_key: 'empty_history',
|
|
146
|
+
tier: 'consult',
|
|
147
|
+
success_count: 0,
|
|
148
|
+
failure_count: 0,
|
|
149
|
+
total_actions: 0,
|
|
150
|
+
history: '[]'
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
map = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
154
|
+
expect(map.domains['empty_history'][:history]).to eq([])
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
describe 'round-trip persistence' do
|
|
159
|
+
it 'saves and restores earned tier' do
|
|
160
|
+
# First instance: build history and promote
|
|
161
|
+
map1 = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
162
|
+
15.times { map1.record_outcome('tasks', success: true) }
|
|
163
|
+
map1.set_tier('tasks', :act_notify)
|
|
164
|
+
map1.save_to_local
|
|
165
|
+
|
|
166
|
+
# Second instance: loads from DB
|
|
167
|
+
map2 = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
168
|
+
expect(map2.get_tier('tasks')).to eq(:act_notify)
|
|
169
|
+
expect(map2.domains['tasks'][:success_count]).to eq(15)
|
|
170
|
+
expect(map2.domains['tasks'][:total_actions]).to eq(15)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it 'round-trips multiple domains independently' do
|
|
174
|
+
map1 = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
175
|
+
map1.set_tier('email', :autonomous)
|
|
176
|
+
map1.record_outcome('calendar', success: true)
|
|
177
|
+
map1.record_outcome('calendar', success: false)
|
|
178
|
+
map1.save_to_local
|
|
179
|
+
|
|
180
|
+
map2 = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
181
|
+
expect(map2.get_tier('email')).to eq(:autonomous)
|
|
182
|
+
expect(map2.get_tier('calendar')).to eq(:consult)
|
|
183
|
+
expect(map2.domains['calendar'][:success_count]).to eq(1)
|
|
184
|
+
expect(map2.domains['calendar'][:failure_count]).to eq(1)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
describe 'graceful no-op when Legion::Data::Local is unavailable' do
|
|
189
|
+
before do
|
|
190
|
+
# Simulate no Legion::Data::Local defined
|
|
191
|
+
hide_const('Legion::Data::Local')
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it 'save_to_local does nothing without raising' do
|
|
195
|
+
map = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
196
|
+
map.record_outcome('email', success: true)
|
|
197
|
+
expect { map.save_to_local }.not_to raise_error
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it 'load_from_local does nothing without raising (no DB loaded at init)' do
|
|
201
|
+
expect { Legion::Extensions::Consent::Helpers::ConsentMap.new }.not_to raise_error
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it 'starts with default in-memory state' do
|
|
205
|
+
map = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
206
|
+
expect(map.get_tier('email')).to eq(:consult)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
describe 'graceful no-op when Legion::Data::Local is defined but not connected' do
|
|
211
|
+
before do
|
|
212
|
+
stub_const('Legion::Data::Local', Module.new do
|
|
213
|
+
def self.connected?
|
|
214
|
+
false
|
|
215
|
+
end
|
|
216
|
+
end)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it 'save_to_local does nothing without raising' do
|
|
220
|
+
map = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
221
|
+
map.record_outcome('email', success: true)
|
|
222
|
+
expect { map.save_to_local }.not_to raise_error
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it 'initialize completes without raising' do
|
|
226
|
+
expect { Legion::Extensions::Consent::Helpers::ConsentMap.new }.not_to raise_error
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
it 'starts with default in-memory state' do
|
|
230
|
+
map = Legion::Extensions::Consent::Helpers::ConsentMap.new
|
|
231
|
+
expect(map.get_tier('email')).to eq(:consult)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Logging
|
|
7
|
+
def self.debug(_msg); end
|
|
8
|
+
def self.info(_msg); end
|
|
9
|
+
def self.warn(_msg); end
|
|
10
|
+
def self.error(_msg); end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require 'legion/extensions/consent'
|
|
15
|
+
|
|
16
|
+
RSpec.configure do |config|
|
|
17
|
+
config.example_status_persistence_file_path = '.rspec_status'
|
|
18
|
+
config.disable_monkey_patching!
|
|
19
|
+
config.expect_with(:rspec) { |c| c.syntax = :expect }
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lex-consent
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Esity
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: sequel
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.70'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.70'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: sqlite3
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
description: Four-tier consent gradient with earned autonomy for brain-modeled agentic
|
|
41
|
+
AI
|
|
42
|
+
email:
|
|
43
|
+
- matthewdiverson@gmail.com
|
|
44
|
+
executables: []
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- Gemfile
|
|
49
|
+
- lex-consent.gemspec
|
|
50
|
+
- lib/legion/extensions/consent.rb
|
|
51
|
+
- lib/legion/extensions/consent/actors/tier_evaluation.rb
|
|
52
|
+
- lib/legion/extensions/consent/client.rb
|
|
53
|
+
- lib/legion/extensions/consent/helpers/consent_map.rb
|
|
54
|
+
- lib/legion/extensions/consent/helpers/tiers.rb
|
|
55
|
+
- lib/legion/extensions/consent/local_migrations/20260316000010_create_consent_domains.rb
|
|
56
|
+
- lib/legion/extensions/consent/runners/consent.rb
|
|
57
|
+
- lib/legion/extensions/consent/version.rb
|
|
58
|
+
- spec/legion/extensions/consent/actors/tier_evaluation_spec.rb
|
|
59
|
+
- spec/legion/extensions/consent/client_spec.rb
|
|
60
|
+
- spec/legion/extensions/consent/helpers/tiers_spec.rb
|
|
61
|
+
- spec/legion/extensions/consent/runners/consent_spec.rb
|
|
62
|
+
- spec/local_persistence_spec.rb
|
|
63
|
+
- spec/spec_helper.rb
|
|
64
|
+
homepage: https://github.com/LegionIO/lex-consent
|
|
65
|
+
licenses:
|
|
66
|
+
- MIT
|
|
67
|
+
metadata:
|
|
68
|
+
homepage_uri: https://github.com/LegionIO/lex-consent
|
|
69
|
+
source_code_uri: https://github.com/LegionIO/lex-consent
|
|
70
|
+
documentation_uri: https://github.com/LegionIO/lex-consent
|
|
71
|
+
changelog_uri: https://github.com/LegionIO/lex-consent
|
|
72
|
+
bug_tracker_uri: https://github.com/LegionIO/lex-consent/issues
|
|
73
|
+
rubygems_mfa_required: 'true'
|
|
74
|
+
rdoc_options: []
|
|
75
|
+
require_paths:
|
|
76
|
+
- lib
|
|
77
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '3.4'
|
|
82
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - ">="
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: '0'
|
|
87
|
+
requirements: []
|
|
88
|
+
rubygems_version: 3.6.9
|
|
89
|
+
specification_version: 4
|
|
90
|
+
summary: LEX Consent
|
|
91
|
+
test_files: []
|