lex-governance 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 872e980260f49400124a887b0a30dc0acb5a763440d17b20afc7c837a1e0ce4d
4
+ data.tar.gz: d6912d4151a41cac307e1e828733306b7ea1f32d85c126b13f2b8b5ac2fe4cb7
5
+ SHA512:
6
+ metadata.gz: 7c6bf0e53ee3d3b6de474c5c3ff4f5d4d0dfc41dfa2ccf288af43e94056937ba4d4202426caf6ad24a8258fef0a330ef96e13f1b4db2935e82f367b9d03d4e0f
7
+ data.tar.gz: a62fda0ef8c6434646bed44760708f28bc8826c92a3e0a4572b2a68693c5ceac23defcd3b4f43b529ccb2050eb44a67ec8a65d9f0cfb16172ff5f347810ff66d
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+
10
+ gem 'legion-gaia', path: '../../legion-gaia'
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/governance/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-governance'
7
+ spec.version = Legion::Extensions::Governance::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Governance'
12
+ spec.description = 'Four-layer distributed governance protocol for brain-modeled agentic AI'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-governance'
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-governance'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-governance'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-governance'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-governance/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-governance.gemspec Gemfile]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ 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 Governance
8
+ module Actor
9
+ class VoteTimeout < Legion::Extensions::Actors::Every
10
+ def runner_class
11
+ Legion::Extensions::Governance::Runners::Governance
12
+ end
13
+
14
+ def runner_function
15
+ 'timeout_proposals'
16
+ end
17
+
18
+ def time
19
+ 300
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/governance/helpers/layers'
4
+ require 'legion/extensions/governance/helpers/proposal'
5
+ require 'legion/extensions/governance/runners/governance'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Governance
10
+ class Client
11
+ include Runners::Governance
12
+
13
+ def initialize(**)
14
+ @proposal_store = Helpers::Proposal.new
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :proposal_store
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Governance
6
+ module Helpers
7
+ module Layers
8
+ # Four governance layers (spec: governance-protocol-spec.md)
9
+ GOVERNANCE_LAYERS = %i[agent_validation anomaly_detection human_deliberation transparency].freeze
10
+
11
+ # Council quorum requirements
12
+ MIN_COUNCIL_SIZE = 3
13
+ QUORUM_FRACTION = 0.66
14
+ VOTE_TIMEOUT = 86_400 # 24 hours
15
+ PROPOSAL_CATEGORIES = %i[policy_change resource_allocation access_control emergency protocol_update].freeze
16
+
17
+ module_function
18
+
19
+ def valid_layer?(layer)
20
+ GOVERNANCE_LAYERS.include?(layer)
21
+ end
22
+
23
+ def valid_category?(category)
24
+ PROPOSAL_CATEGORIES.include?(category)
25
+ end
26
+
27
+ def quorum_met?(votes, council_size)
28
+ return false if council_size < MIN_COUNCIL_SIZE
29
+
30
+ votes >= (council_size * QUORUM_FRACTION).ceil
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Governance
8
+ module Helpers
9
+ class Proposal
10
+ attr_reader :proposals
11
+
12
+ def initialize
13
+ @proposals = {}
14
+ end
15
+
16
+ def create(category:, description:, proposer:, council_size: Layers::MIN_COUNCIL_SIZE)
17
+ id = SecureRandom.uuid
18
+ @proposals[id] = {
19
+ proposal_id: id,
20
+ category: category,
21
+ description: description,
22
+ proposer: proposer,
23
+ council_size: council_size,
24
+ votes_for: [],
25
+ votes_against: [],
26
+ status: :open,
27
+ created_at: Time.now.utc,
28
+ resolved_at: nil
29
+ }
30
+ id
31
+ end
32
+
33
+ def vote(proposal_id, voter:, approve:)
34
+ prop = @proposals[proposal_id]
35
+ return nil unless prop && prop[:status] == :open
36
+
37
+ # Prevent double-voting
38
+ all_voters = prop[:votes_for] + prop[:votes_against]
39
+ return :already_voted if all_voters.include?(voter)
40
+
41
+ if approve
42
+ prop[:votes_for] << voter
43
+ else
44
+ prop[:votes_against] << voter
45
+ end
46
+
47
+ check_resolution(proposal_id)
48
+ end
49
+
50
+ def get(proposal_id)
51
+ @proposals[proposal_id]
52
+ end
53
+
54
+ def open_proposals
55
+ @proposals.values.select { |p| p[:status] == :open }
56
+ end
57
+
58
+ def resolve_timed_out(proposal_id)
59
+ prop = @proposals[proposal_id]
60
+ return nil unless prop && prop[:status] == :open
61
+
62
+ prop[:status] = :timed_out
63
+ prop[:resolved_at] = Time.now.utc
64
+ prop
65
+ end
66
+
67
+ private
68
+
69
+ def check_resolution(proposal_id)
70
+ prop = @proposals[proposal_id]
71
+ total_votes = prop[:votes_for].size + prop[:votes_against].size
72
+
73
+ if Layers.quorum_met?(prop[:votes_for].size, prop[:council_size])
74
+ prop[:status] = :approved
75
+ prop[:resolved_at] = Time.now.utc
76
+ :approved
77
+ elsif Layers.quorum_met?(prop[:votes_against].size, prop[:council_size]) ||
78
+ total_votes >= prop[:council_size]
79
+ prop[:status] = :rejected
80
+ prop[:resolved_at] = Time.now.utc
81
+ :rejected
82
+ else
83
+ :pending
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Governance
6
+ module Runners
7
+ module Governance
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def create_proposal(category:, description:, proposer:, council_size: nil, **)
12
+ return { error: :invalid_category, valid: Helpers::Layers::PROPOSAL_CATEGORIES } unless Helpers::Layers.valid_category?(category)
13
+
14
+ size = council_size || Helpers::Layers::MIN_COUNCIL_SIZE
15
+ id = proposal_store.create(category: category, description: description,
16
+ proposer: proposer, council_size: size)
17
+ Legion::Logging.info "[governance] proposal created: id=#{id[0..7]} category=#{category} proposer=#{proposer} council=#{size}"
18
+ { proposal_id: id, category: category, status: :open }
19
+ end
20
+
21
+ def vote_on_proposal(proposal_id:, voter:, approve:, **)
22
+ result = proposal_store.vote(proposal_id, voter: voter, approve: approve)
23
+ case result
24
+ when nil
25
+ Legion::Logging.debug "[governance] vote failed: proposal=#{proposal_id[0..7]} not found or closed"
26
+ { error: :not_found_or_closed }
27
+ when :already_voted
28
+ Legion::Logging.debug "[governance] vote failed: proposal=#{proposal_id[0..7]} voter=#{voter} already voted"
29
+ { error: :already_voted }
30
+ else
31
+ Legion::Logging.info "[governance] vote: proposal=#{proposal_id[0..7]} voter=#{voter} approve=#{approve} resolution=#{result}"
32
+ { voted: true, resolution: result }
33
+ end
34
+ end
35
+
36
+ def get_proposal(proposal_id:, **)
37
+ prop = proposal_store.get(proposal_id)
38
+ Legion::Logging.debug "[governance] get: proposal=#{proposal_id[0..7]} found=#{!prop.nil?}"
39
+ prop ? { found: true, proposal: prop } : { found: false }
40
+ end
41
+
42
+ def open_proposals(**)
43
+ props = proposal_store.open_proposals
44
+ Legion::Logging.debug "[governance] open proposals: count=#{props.size}"
45
+ { proposals: props, count: props.size }
46
+ end
47
+
48
+ def timeout_proposals(**)
49
+ open = proposal_store.open_proposals
50
+ timed = open.select { |p| Time.now.utc - p[:created_at] > Helpers::Layers::VOTE_TIMEOUT }
51
+ timed.each { |p| proposal_store.resolve_timed_out(p[:proposal_id]) }
52
+ timed_ids = timed.map { |p| p[:proposal_id] }
53
+ Legion::Logging.debug "[governance] vote timeout sweep: open=#{open.size} timed_out=#{timed.size}"
54
+ { checked: open.size, timed_out: timed.size, timed_out_ids: timed_ids }
55
+ end
56
+
57
+ def validate_action(layer:, action: nil, _context: {}, **) # rubocop:disable Lint/UnusedMethodArgument
58
+ return { error: :invalid_layer } unless Helpers::Layers.valid_layer?(layer)
59
+
60
+ result = case layer
61
+ when :agent_validation
62
+ { allowed: true, layer: layer, reason: :self_validated }
63
+ when :anomaly_detection
64
+ { allowed: true, layer: layer, reason: :no_anomaly }
65
+ when :human_deliberation
66
+ { allowed: false, layer: layer, reason: :requires_human_approval }
67
+ when :transparency
68
+ { allowed: true, layer: layer, reason: :logged, audit_required: true }
69
+ end
70
+ Legion::Logging.debug "[governance] validate: layer=#{layer} allowed=#{result[:allowed]} reason=#{result[:reason]}"
71
+ result
72
+ end
73
+
74
+ private
75
+
76
+ def proposal_store
77
+ @proposal_store ||= Helpers::Proposal.new
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Governance
6
+ VERSION = '0.1.1'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/governance/version'
4
+ require 'legion/extensions/governance/helpers/layers'
5
+ require 'legion/extensions/governance/helpers/proposal'
6
+ require 'legion/extensions/governance/runners/governance'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Governance
11
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Actors
6
+ class Every; end # rubocop:disable Lint/EmptyClass
7
+ end
8
+ end
9
+ end
10
+
11
+ $LOADED_FEATURES << 'legion/extensions/actors/every'
12
+
13
+ require_relative '../../../../../lib/legion/extensions/governance/actors/vote_timeout'
14
+
15
+ RSpec.describe Legion::Extensions::Governance::Actor::VoteTimeout do
16
+ subject(:actor) { described_class.new }
17
+
18
+ describe '#runner_class' do
19
+ it { expect(actor.runner_class).to eq Legion::Extensions::Governance::Runners::Governance }
20
+ end
21
+
22
+ describe '#runner_function' do
23
+ it { expect(actor.runner_function).to eq 'timeout_proposals' }
24
+ end
25
+
26
+ describe '#time' do
27
+ it { expect(actor.time).to eq 300 }
28
+ end
29
+
30
+ describe '#run_now?' do
31
+ it { expect(actor.run_now?).to be false }
32
+ end
33
+
34
+ describe '#use_runner?' do
35
+ it { expect(actor.use_runner?).to be false }
36
+ end
37
+
38
+ describe '#check_subtask?' do
39
+ it { expect(actor.check_subtask?).to be false }
40
+ end
41
+
42
+ describe '#generate_task?' do
43
+ it { expect(actor.generate_task?).to be false }
44
+ end
45
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/governance/client'
4
+
5
+ RSpec.describe Legion::Extensions::Governance::Client do
6
+ it 'responds to governance runner methods' do
7
+ client = described_class.new
8
+ expect(client).to respond_to(:create_proposal)
9
+ expect(client).to respond_to(:vote_on_proposal)
10
+ expect(client).to respond_to(:get_proposal)
11
+ expect(client).to respond_to(:open_proposals)
12
+ expect(client).to respond_to(:validate_action)
13
+ end
14
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/governance/helpers/layers'
4
+
5
+ RSpec.describe Legion::Extensions::Governance::Helpers::Layers do
6
+ describe 'GOVERNANCE_LAYERS' do
7
+ it 'contains four layers as symbols' do
8
+ expect(described_class::GOVERNANCE_LAYERS.size).to eq(4)
9
+ end
10
+
11
+ it 'includes agent_validation' do
12
+ expect(described_class::GOVERNANCE_LAYERS).to include(:agent_validation)
13
+ end
14
+
15
+ it 'includes anomaly_detection' do
16
+ expect(described_class::GOVERNANCE_LAYERS).to include(:anomaly_detection)
17
+ end
18
+
19
+ it 'includes human_deliberation' do
20
+ expect(described_class::GOVERNANCE_LAYERS).to include(:human_deliberation)
21
+ end
22
+
23
+ it 'includes transparency' do
24
+ expect(described_class::GOVERNANCE_LAYERS).to include(:transparency)
25
+ end
26
+
27
+ it 'is frozen' do
28
+ expect(described_class::GOVERNANCE_LAYERS).to be_frozen
29
+ end
30
+ end
31
+
32
+ describe 'PROPOSAL_CATEGORIES' do
33
+ it 'contains five categories' do
34
+ expect(described_class::PROPOSAL_CATEGORIES.size).to eq(5)
35
+ end
36
+
37
+ it 'includes policy_change' do
38
+ expect(described_class::PROPOSAL_CATEGORIES).to include(:policy_change)
39
+ end
40
+
41
+ it 'includes resource_allocation' do
42
+ expect(described_class::PROPOSAL_CATEGORIES).to include(:resource_allocation)
43
+ end
44
+
45
+ it 'includes access_control' do
46
+ expect(described_class::PROPOSAL_CATEGORIES).to include(:access_control)
47
+ end
48
+
49
+ it 'includes emergency' do
50
+ expect(described_class::PROPOSAL_CATEGORIES).to include(:emergency)
51
+ end
52
+
53
+ it 'includes protocol_update' do
54
+ expect(described_class::PROPOSAL_CATEGORIES).to include(:protocol_update)
55
+ end
56
+
57
+ it 'is frozen' do
58
+ expect(described_class::PROPOSAL_CATEGORIES).to be_frozen
59
+ end
60
+ end
61
+
62
+ describe 'numeric constants' do
63
+ it 'sets MIN_COUNCIL_SIZE to 3' do
64
+ expect(described_class::MIN_COUNCIL_SIZE).to eq(3)
65
+ end
66
+
67
+ it 'sets QUORUM_FRACTION to 0.66' do
68
+ expect(described_class::QUORUM_FRACTION).to eq(0.66)
69
+ end
70
+
71
+ it 'sets VOTE_TIMEOUT to 86400' do
72
+ expect(described_class::VOTE_TIMEOUT).to eq(86_400)
73
+ end
74
+ end
75
+
76
+ describe '.valid_layer?' do
77
+ it 'returns true for agent_validation' do
78
+ expect(described_class.valid_layer?(:agent_validation)).to be true
79
+ end
80
+
81
+ it 'returns true for anomaly_detection' do
82
+ expect(described_class.valid_layer?(:anomaly_detection)).to be true
83
+ end
84
+
85
+ it 'returns true for human_deliberation' do
86
+ expect(described_class.valid_layer?(:human_deliberation)).to be true
87
+ end
88
+
89
+ it 'returns true for transparency' do
90
+ expect(described_class.valid_layer?(:transparency)).to be true
91
+ end
92
+
93
+ it 'returns false for unknown layer symbol' do
94
+ expect(described_class.valid_layer?(:unknown)).to be false
95
+ end
96
+
97
+ it 'returns false for nil' do
98
+ expect(described_class.valid_layer?(nil)).to be false
99
+ end
100
+
101
+ it 'returns false for string version of valid layer' do
102
+ expect(described_class.valid_layer?('agent_validation')).to be false
103
+ end
104
+ end
105
+
106
+ describe '.valid_category?' do
107
+ it 'returns true for policy_change' do
108
+ expect(described_class.valid_category?(:policy_change)).to be true
109
+ end
110
+
111
+ it 'returns true for resource_allocation' do
112
+ expect(described_class.valid_category?(:resource_allocation)).to be true
113
+ end
114
+
115
+ it 'returns true for access_control' do
116
+ expect(described_class.valid_category?(:access_control)).to be true
117
+ end
118
+
119
+ it 'returns true for emergency' do
120
+ expect(described_class.valid_category?(:emergency)).to be true
121
+ end
122
+
123
+ it 'returns true for protocol_update' do
124
+ expect(described_class.valid_category?(:protocol_update)).to be true
125
+ end
126
+
127
+ it 'returns false for unknown category' do
128
+ expect(described_class.valid_category?(:invalid)).to be false
129
+ end
130
+
131
+ it 'returns false for nil' do
132
+ expect(described_class.valid_category?(nil)).to be false
133
+ end
134
+
135
+ it 'returns false for string version of valid category' do
136
+ expect(described_class.valid_category?('emergency')).to be false
137
+ end
138
+ end
139
+
140
+ describe '.quorum_met?' do
141
+ context 'when council_size is below minimum' do
142
+ it 'returns false for council_size of 2' do
143
+ expect(described_class.quorum_met?(2, 2)).to be false
144
+ end
145
+
146
+ it 'returns false for council_size of 1' do
147
+ expect(described_class.quorum_met?(1, 1)).to be false
148
+ end
149
+
150
+ it 'returns false for council_size of 0' do
151
+ expect(described_class.quorum_met?(0, 0)).to be false
152
+ end
153
+ end
154
+
155
+ context 'with minimum council size of 3' do
156
+ it 'returns true when 2 of 3 vote (meets ceil(3 * 0.66) = 2)' do
157
+ expect(described_class.quorum_met?(2, 3)).to be true
158
+ end
159
+
160
+ it 'returns true when all 3 vote' do
161
+ expect(described_class.quorum_met?(3, 3)).to be true
162
+ end
163
+
164
+ it 'returns false when only 1 of 3 vote' do
165
+ expect(described_class.quorum_met?(1, 3)).to be false
166
+ end
167
+ end
168
+
169
+ context 'with larger council sizes' do
170
+ it 'requires ceil(4 * 0.66) = 3 votes for council of 4' do
171
+ expect(described_class.quorum_met?(3, 4)).to be true
172
+ expect(described_class.quorum_met?(2, 4)).to be false
173
+ end
174
+
175
+ it 'requires ceil(5 * 0.66) = 4 votes for council of 5' do
176
+ expect(described_class.quorum_met?(4, 5)).to be true
177
+ expect(described_class.quorum_met?(3, 5)).to be false
178
+ end
179
+
180
+ it 'requires ceil(9 * 0.66) = 6 votes for council of 9' do
181
+ expect(described_class.quorum_met?(6, 9)).to be true
182
+ expect(described_class.quorum_met?(5, 9)).to be false
183
+ end
184
+ end
185
+
186
+ it 'returns false for zero votes regardless of council size' do
187
+ expect(described_class.quorum_met?(0, 3)).to be false
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/governance/helpers/layers'
4
+ require 'legion/extensions/governance/helpers/proposal'
5
+
6
+ RSpec.describe Legion::Extensions::Governance::Helpers::Proposal do
7
+ subject(:proposal_store) { described_class.new }
8
+
9
+ describe '#initialize' do
10
+ it 'starts with an empty proposals hash' do
11
+ expect(proposal_store.proposals).to eq({})
12
+ end
13
+ end
14
+
15
+ describe '#create' do
16
+ let(:created_id) do
17
+ proposal_store.create(category: :policy_change, description: 'Enable new policy', proposer: 'agent-1')
18
+ end
19
+
20
+ it 'returns a UUID string' do
21
+ expect(created_id).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/)
22
+ end
23
+
24
+ it 'stores the proposal under the returned ID' do
25
+ expect(proposal_store.proposals[created_id]).not_to be_nil
26
+ end
27
+
28
+ it 'stores the correct category' do
29
+ expect(proposal_store.proposals[created_id][:category]).to eq(:policy_change)
30
+ end
31
+
32
+ it 'stores the correct description' do
33
+ expect(proposal_store.proposals[created_id][:description]).to eq('Enable new policy')
34
+ end
35
+
36
+ it 'stores the correct proposer' do
37
+ expect(proposal_store.proposals[created_id][:proposer]).to eq('agent-1')
38
+ end
39
+
40
+ it 'defaults council_size to MIN_COUNCIL_SIZE' do
41
+ expect(proposal_store.proposals[created_id][:council_size])
42
+ .to eq(Legion::Extensions::Governance::Helpers::Layers::MIN_COUNCIL_SIZE)
43
+ end
44
+
45
+ it 'uses the provided council_size when specified' do
46
+ id = proposal_store.create(category: :emergency, description: 'urgent', proposer: 'agent-x', council_size: 7)
47
+ expect(proposal_store.proposals[id][:council_size]).to eq(7)
48
+ end
49
+
50
+ it 'initializes with status :open' do
51
+ expect(proposal_store.proposals[created_id][:status]).to eq(:open)
52
+ end
53
+
54
+ it 'initializes votes_for as empty array' do
55
+ expect(proposal_store.proposals[created_id][:votes_for]).to eq([])
56
+ end
57
+
58
+ it 'initializes votes_against as empty array' do
59
+ expect(proposal_store.proposals[created_id][:votes_against]).to eq([])
60
+ end
61
+
62
+ it 'sets created_at to a Time object' do
63
+ expect(proposal_store.proposals[created_id][:created_at]).to be_a(Time)
64
+ end
65
+
66
+ it 'sets resolved_at to nil initially' do
67
+ expect(proposal_store.proposals[created_id][:resolved_at]).to be_nil
68
+ end
69
+
70
+ it 'generates unique IDs for separate calls' do
71
+ id1 = proposal_store.create(category: :policy_change, description: 'a', proposer: 'x')
72
+ id2 = proposal_store.create(category: :policy_change, description: 'b', proposer: 'y')
73
+ expect(id1).not_to eq(id2)
74
+ end
75
+ end
76
+
77
+ describe '#get' do
78
+ it 'returns the proposal hash for a valid ID' do
79
+ id = proposal_store.create(category: :policy_change, description: 'test', proposer: 'agent-1')
80
+ result = proposal_store.get(id)
81
+ expect(result[:proposal_id]).to eq(id)
82
+ end
83
+
84
+ it 'returns nil for an unknown ID' do
85
+ expect(proposal_store.get('nonexistent-uuid')).to be_nil
86
+ end
87
+ end
88
+
89
+ describe '#open_proposals' do
90
+ it 'returns empty array when no proposals exist' do
91
+ expect(proposal_store.open_proposals).to eq([])
92
+ end
93
+
94
+ it 'returns all open proposals' do
95
+ proposal_store.create(category: :policy_change, description: 'first', proposer: 'a1')
96
+ proposal_store.create(category: :emergency, description: 'second', proposer: 'a2')
97
+ expect(proposal_store.open_proposals.size).to eq(2)
98
+ end
99
+
100
+ it 'excludes resolved proposals' do
101
+ id = proposal_store.create(category: :policy_change, description: 'vote me', proposer: 'a1', council_size: 3)
102
+ proposal_store.vote(id, voter: 'v1', approve: true)
103
+ proposal_store.vote(id, voter: 'v2', approve: true)
104
+ expect(proposal_store.open_proposals).to be_empty
105
+ end
106
+ end
107
+
108
+ describe '#vote' do
109
+ let(:proposal_id) do
110
+ proposal_store.create(category: :policy_change, description: 'test', proposer: 'agent-1', council_size: 3)
111
+ end
112
+
113
+ it 'returns nil for an unknown proposal ID' do
114
+ expect(proposal_store.vote('no-such-id', voter: 'v1', approve: true)).to be_nil
115
+ end
116
+
117
+ it 'returns :already_voted when the same voter votes twice' do
118
+ proposal_store.vote(proposal_id, voter: 'v1', approve: true)
119
+ result = proposal_store.vote(proposal_id, voter: 'v1', approve: false)
120
+ expect(result).to eq(:already_voted)
121
+ end
122
+
123
+ it 'prevents double voting even when switching approve/reject' do
124
+ proposal_store.vote(proposal_id, voter: 'v1', approve: false)
125
+ result = proposal_store.vote(proposal_id, voter: 'v1', approve: true)
126
+ expect(result).to eq(:already_voted)
127
+ end
128
+
129
+ it 'adds approve vote to votes_for' do
130
+ proposal_store.vote(proposal_id, voter: 'v1', approve: true)
131
+ expect(proposal_store.proposals[proposal_id][:votes_for]).to include('v1')
132
+ end
133
+
134
+ it 'adds reject vote to votes_against' do
135
+ proposal_store.vote(proposal_id, voter: 'v1', approve: false)
136
+ expect(proposal_store.proposals[proposal_id][:votes_against]).to include('v1')
137
+ end
138
+
139
+ it 'returns :pending when quorum is not yet met' do
140
+ result = proposal_store.vote(proposal_id, voter: 'v1', approve: true)
141
+ expect(result).to eq(:pending)
142
+ end
143
+
144
+ context 'when quorum for approval is met' do
145
+ it 'returns :approved and sets status' do
146
+ proposal_store.vote(proposal_id, voter: 'v1', approve: true)
147
+ result = proposal_store.vote(proposal_id, voter: 'v2', approve: true)
148
+ expect(result).to eq(:approved)
149
+ expect(proposal_store.proposals[proposal_id][:status]).to eq(:approved)
150
+ end
151
+
152
+ it 'sets resolved_at on approval' do
153
+ proposal_store.vote(proposal_id, voter: 'v1', approve: true)
154
+ proposal_store.vote(proposal_id, voter: 'v2', approve: true)
155
+ expect(proposal_store.proposals[proposal_id][:resolved_at]).to be_a(Time)
156
+ end
157
+ end
158
+
159
+ context 'when quorum for rejection is met' do
160
+ it 'returns :rejected when enough votes against' do
161
+ proposal_store.vote(proposal_id, voter: 'v1', approve: false)
162
+ result = proposal_store.vote(proposal_id, voter: 'v2', approve: false)
163
+ expect(result).to eq(:rejected)
164
+ expect(proposal_store.proposals[proposal_id][:status]).to eq(:rejected)
165
+ end
166
+
167
+ it 'sets resolved_at on rejection' do
168
+ proposal_store.vote(proposal_id, voter: 'v1', approve: false)
169
+ proposal_store.vote(proposal_id, voter: 'v2', approve: false)
170
+ expect(proposal_store.proposals[proposal_id][:resolved_at]).to be_a(Time)
171
+ end
172
+
173
+ it 'returns :rejected when all council members voted and no quorum for approval' do
174
+ proposal_store.vote(proposal_id, voter: 'v1', approve: true)
175
+ proposal_store.vote(proposal_id, voter: 'v2', approve: false)
176
+ result = proposal_store.vote(proposal_id, voter: 'v3', approve: false)
177
+ expect(result).to eq(:rejected)
178
+ end
179
+ end
180
+
181
+ it 'returns nil when voting on a resolved (approved) proposal' do
182
+ proposal_store.vote(proposal_id, voter: 'v1', approve: true)
183
+ proposal_store.vote(proposal_id, voter: 'v2', approve: true)
184
+ result = proposal_store.vote(proposal_id, voter: 'v3', approve: true)
185
+ expect(result).to be_nil
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/governance/client'
4
+
5
+ RSpec.describe Legion::Extensions::Governance::Runners::Governance do
6
+ let(:client) { Legion::Extensions::Governance::Client.new }
7
+
8
+ describe '#create_proposal' do
9
+ it 'creates a proposal' do
10
+ result = client.create_proposal(category: :policy_change, description: 'test', proposer: 'agent-1')
11
+ expect(result[:proposal_id]).to match(/\A[0-9a-f-]{36}\z/)
12
+ expect(result[:status]).to eq(:open)
13
+ end
14
+
15
+ it 'rejects invalid category' do
16
+ result = client.create_proposal(category: :invalid, description: 'test', proposer: 'agent-1')
17
+ expect(result[:error]).to eq(:invalid_category)
18
+ end
19
+ end
20
+
21
+ describe '#vote_on_proposal' do
22
+ it 'records a vote' do
23
+ prop = client.create_proposal(category: :policy_change, description: 'test', proposer: 'agent-1', council_size: 3)
24
+ result = client.vote_on_proposal(proposal_id: prop[:proposal_id], voter: 'agent-2', approve: true)
25
+ expect(result[:voted]).to be true
26
+ end
27
+
28
+ it 'prevents double voting' do
29
+ prop = client.create_proposal(category: :policy_change, description: 'test', proposer: 'agent-1', council_size: 3)
30
+ client.vote_on_proposal(proposal_id: prop[:proposal_id], voter: 'agent-2', approve: true)
31
+ result = client.vote_on_proposal(proposal_id: prop[:proposal_id], voter: 'agent-2', approve: true)
32
+ expect(result[:error]).to eq(:already_voted)
33
+ end
34
+
35
+ it 'approves with quorum' do
36
+ prop = client.create_proposal(category: :policy_change, description: 'test', proposer: 'agent-1', council_size: 3)
37
+ client.vote_on_proposal(proposal_id: prop[:proposal_id], voter: 'v1', approve: true)
38
+ result = client.vote_on_proposal(proposal_id: prop[:proposal_id], voter: 'v2', approve: true)
39
+ expect(result[:resolution]).to eq(:approved)
40
+ end
41
+ end
42
+
43
+ describe '#validate_action' do
44
+ it 'allows agent_validation' do
45
+ result = client.validate_action(layer: :agent_validation, action: 'test')
46
+ expect(result[:allowed]).to be true
47
+ end
48
+
49
+ it 'blocks human_deliberation' do
50
+ result = client.validate_action(layer: :human_deliberation, action: 'test')
51
+ expect(result[:allowed]).to be false
52
+ end
53
+
54
+ it 'rejects invalid layer' do
55
+ result = client.validate_action(layer: :invalid, action: 'test')
56
+ expect(result[:error]).to eq(:invalid_layer)
57
+ end
58
+ end
59
+
60
+ describe '#open_proposals' do
61
+ it 'lists open proposals' do
62
+ client.create_proposal(category: :policy_change, description: 'test', proposer: 'agent-1')
63
+ result = client.open_proposals
64
+ expect(result[:count]).to eq(1)
65
+ end
66
+ end
67
+
68
+ describe '#timeout_proposals' do
69
+ it 'returns zero timed_out when no proposals exist' do
70
+ result = client.timeout_proposals
71
+ expect(result[:checked]).to eq(0)
72
+ expect(result[:timed_out]).to eq(0)
73
+ expect(result[:timed_out_ids]).to eq([])
74
+ end
75
+
76
+ it 'returns zero timed_out for recently created proposals' do
77
+ client.create_proposal(category: :policy_change, description: 'fresh', proposer: 'agent-1')
78
+ result = client.timeout_proposals
79
+ expect(result[:timed_out]).to eq(0)
80
+ end
81
+
82
+ it 'times out proposals older than VOTE_TIMEOUT' do
83
+ prop = client.create_proposal(category: :policy_change, description: 'old', proposer: 'agent-1')
84
+ store = client.instance_variable_get(:@proposal_store)
85
+ store.proposals[prop[:proposal_id]][:created_at] = Time.now.utc - (Legion::Extensions::Governance::Helpers::Layers::VOTE_TIMEOUT + 1)
86
+ result = client.timeout_proposals
87
+ expect(result[:timed_out]).to eq(1)
88
+ expect(result[:timed_out_ids]).to include(prop[:proposal_id])
89
+ end
90
+
91
+ it 'does not time out already-resolved proposals' do
92
+ prop = client.create_proposal(category: :policy_change, description: 'resolved', proposer: 'agent-1', council_size: 3)
93
+ store = client.instance_variable_get(:@proposal_store)
94
+ store.proposals[prop[:proposal_id]][:created_at] = Time.now.utc - (Legion::Extensions::Governance::Helpers::Layers::VOTE_TIMEOUT + 1)
95
+ client.vote_on_proposal(proposal_id: prop[:proposal_id], voter: 'v1', approve: true)
96
+ client.vote_on_proposal(proposal_id: prop[:proposal_id], voter: 'v2', approve: true)
97
+ result = client.timeout_proposals
98
+ expect(result[:timed_out]).to eq(0)
99
+ end
100
+ end
101
+ end
@@ -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/governance'
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,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-governance
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
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: legion-gaia
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: Four-layer distributed governance protocol for brain-modeled agentic
27
+ AI
28
+ email:
29
+ - matthewdiverson@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - Gemfile
35
+ - lex-governance.gemspec
36
+ - lib/legion/extensions/governance.rb
37
+ - lib/legion/extensions/governance/actors/vote_timeout.rb
38
+ - lib/legion/extensions/governance/client.rb
39
+ - lib/legion/extensions/governance/helpers/layers.rb
40
+ - lib/legion/extensions/governance/helpers/proposal.rb
41
+ - lib/legion/extensions/governance/runners/governance.rb
42
+ - lib/legion/extensions/governance/version.rb
43
+ - spec/legion/extensions/governance/actors/vote_timeout_spec.rb
44
+ - spec/legion/extensions/governance/client_spec.rb
45
+ - spec/legion/extensions/governance/helpers/layers_spec.rb
46
+ - spec/legion/extensions/governance/helpers/proposal_spec.rb
47
+ - spec/legion/extensions/governance/runners/governance_spec.rb
48
+ - spec/spec_helper.rb
49
+ homepage: https://github.com/LegionIO/lex-governance
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: https://github.com/LegionIO/lex-governance
54
+ source_code_uri: https://github.com/LegionIO/lex-governance
55
+ documentation_uri: https://github.com/LegionIO/lex-governance
56
+ changelog_uri: https://github.com/LegionIO/lex-governance
57
+ bug_tracker_uri: https://github.com/LegionIO/lex-governance/issues
58
+ rubygems_mfa_required: 'true'
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '3.4'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.6.9
74
+ specification_version: 4
75
+ summary: LEX Governance
76
+ test_files: []