lex-apollo 0.4.30 → 0.4.44

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/README.md +42 -5
  4. data/lib/legion/extensions/apollo/actors/contradiction_scanner.rb +14 -11
  5. data/lib/legion/extensions/apollo/actors/gas_subscriber.rb +4 -6
  6. data/lib/legion/extensions/apollo/api.rb +19 -9
  7. data/lib/legion/extensions/apollo/helpers/capability.rb +8 -20
  8. data/lib/legion/extensions/apollo/helpers/confidence.rb +1 -1
  9. data/lib/legion/extensions/apollo/helpers/graph_query.rb +30 -46
  10. data/lib/legion/extensions/apollo/helpers/writeback.rb +9 -13
  11. data/lib/legion/extensions/apollo/runners/gas.rb +90 -20
  12. data/lib/legion/extensions/apollo/runners/knowledge.rb +152 -104
  13. data/lib/legion/extensions/apollo/runners/maintenance.rb +133 -34
  14. data/lib/legion/extensions/apollo/runners/request.rb +4 -5
  15. data/lib/legion/extensions/apollo/transport.rb +20 -0
  16. data/lib/legion/extensions/apollo/version.rb +1 -1
  17. data/spec/legion/extensions/apollo/actors/contradiction_scanner_spec.rb +38 -0
  18. data/spec/legion/extensions/apollo/actors/gas_subscriber_spec.rb +4 -14
  19. data/spec/legion/extensions/apollo/api_spec.rb +11 -21
  20. data/spec/legion/extensions/apollo/contradiction_spec.rb +0 -9
  21. data/spec/legion/extensions/apollo/helpers/capability_spec.rb +2 -2
  22. data/spec/legion/extensions/apollo/helpers/writeback_spec.rb +29 -1
  23. data/spec/legion/extensions/apollo/runners/corroboration_integration_spec.rb +191 -0
  24. data/spec/legion/extensions/apollo/runners/gas_deposit_integration_spec.rb +92 -0
  25. data/spec/legion/extensions/apollo/runners/gas_spec.rb +27 -33
  26. data/spec/legion/extensions/apollo/runners/ingest_transaction_integration_spec.rb +114 -0
  27. data/spec/legion/extensions/apollo/runners/knowledge_spec.rb +47 -57
  28. data/spec/legion/extensions/apollo/runners/lifecycle_integration_spec.rb +165 -0
  29. data/spec/legion/extensions/apollo/runners/maintenance_spec.rb +22 -108
  30. data/spec/legion/extensions/apollo/runners/request_spec.rb +43 -26
  31. metadata +34 -2
  32. data/spec/legion/extensions/apollo/helpers/graph_query_spec.rb +0 -100
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'open3'
5
+ require 'rbconfig'
6
+ require 'json'
7
+
8
+ RSpec.describe 'Apollo corroboration persistence' do
9
+ let(:candidate) do
10
+ { id: 1, status: 'candidate', content_type: 'fact', knowledge_domain: 'clinical', access_scope: 'global',
11
+ confidence: 0.5, source_provider: 'openai', source_channel: 'source-a' }
12
+ end
13
+ let(:confirmed) do
14
+ { id: 2, status: 'confirmed', content_type: 'fact', knowledge_domain: 'clinical', access_scope: 'global',
15
+ confidence: 0.8, source_provider: 'claude', source_channel: 'source-b' }
16
+ end
17
+
18
+ let(:corroboration_script) do
19
+ <<~'RUBY'
20
+ require 'sequel'
21
+ require 'rspec'
22
+ require 'spec_helper'
23
+ require 'json'
24
+ db = Sequel.sqlite
25
+ db.create_table(:apollo_entries) do
26
+ primary_key :id
27
+ String :status
28
+ String :content_type
29
+ String :knowledge_domain
30
+ String :access_scope
31
+ Integer :identity_principal_id
32
+ Integer :identity_id
33
+ String :source_provider
34
+ String :source_channel
35
+ String :embedding
36
+ Float :confidence
37
+ String :source_context
38
+ DateTime :confirmed_at
39
+ DateTime :updated_at
40
+ end
41
+ db.create_table(:apollo_relations) do
42
+ primary_key :id
43
+ Integer :from_entry_id
44
+ Integer :to_entry_id
45
+ String :relation_type
46
+ String :source_agent
47
+ Float :weight
48
+ end
49
+ db.create_table(:identities) do
50
+ Integer :id
51
+ Integer :principal_id
52
+ end
53
+ db.create_table(:identity_group_memberships) do
54
+ Integer :principal_id
55
+ Integer :group_id
56
+ end
57
+ Legion::Data.const_set(:Model, Module.new) unless defined?(Legion::Data::Model)
58
+ Legion::Data::Model.const_set(:Apollo, Module.new)
59
+ Legion::Data::Model::Apollo.const_set(:Entry, Class.new(Sequel::Model(db[:apollo_entries])))
60
+ Legion::Data::Model::Apollo.const_set(:Relation, Class.new(Sequel::Model(db[:apollo_relations])))
61
+ input = ::JSON.parse($stdin.read, symbolize_names: true)
62
+ input[:entries].each { |entry| db[:apollo_entries].insert(entry.merge(embedding: '[1.0,0.0]')) }
63
+ input[:memberships].each { |membership| db[:identity_group_memberships].insert(membership) }
64
+ input[:identities].each { |identity| db[:identities].insert(identity) }
65
+ runner = Object.new.extend(Legion::Extensions::Apollo::Runners::Maintenance)
66
+ results = Array.new(input[:runs] || 1) { runner.check_corroboration }
67
+ state = { result: results.last, results: results, entries: db[:apollo_entries].order(:id).all,
68
+ relations: db[:apollo_relations].all }
69
+ puts "APOLLO_RESULT=#{::JSON.generate(state)}"
70
+ RUBY
71
+ end
72
+
73
+ def corroboration_state(entries:, memberships: [], identities: [], runs: 1)
74
+ stdout, stderr, status = Open3.capture3(
75
+ RbConfig.ruby, '-Ilib', '-Ispec', '-e', corroboration_script,
76
+ chdir: File.expand_path('../../../../..', __dir__),
77
+ stdin_data: JSON.generate(entries: entries, memberships: memberships, identities: identities, runs: runs)
78
+ )
79
+ line = stdout.lines.find { |output| output.start_with?('APOLLO_RESULT=') }
80
+ raise "Corroboration process failed: #{stderr}\n#{stdout}" unless status.success? && line
81
+
82
+ JSON.parse(line.delete_prefix('APOLLO_RESULT='), symbolize_names: true)
83
+ end
84
+
85
+ it 'rejects other domains, inactive states, other content types, and dependent sources' do
86
+ state = corroboration_state(entries: [
87
+ candidate,
88
+ confirmed.merge(knowledge_domain: 'general'),
89
+ confirmed.merge(id: 3, status: 'archived'),
90
+ confirmed.merge(id: 4, status: 'disputed'),
91
+ confirmed.merge(id: 5, content_type: 'concept'),
92
+ confirmed.merge(id: 6, source_provider: 'openai'),
93
+ confirmed.merge(id: 7, source_channel: 'source-a')
94
+ ])
95
+
96
+ expect(state[:result]).to include(success: true, promoted: 0)
97
+ expect(state[:entries].first).to include(status: 'candidate', confidence: 0.5)
98
+ expect(state[:relations]).to eq([])
99
+ end
100
+
101
+ it 'corroborates private knowledge only with eligible evidence owned by the same principal' do
102
+ state = corroboration_state(entries: [
103
+ candidate.merge(access_scope: 'private', identity_principal_id: 42),
104
+ confirmed.merge(access_scope: 'private', identity_principal_id: 99),
105
+ confirmed.merge(id: 3, access_scope: 'private', identity_principal_id: 42, knowledge_domain: 'other'),
106
+ confirmed.merge(id: 4, access_scope: 'private', identity_principal_id: 42)
107
+ ])
108
+
109
+ expect(state[:result]).to include(success: true, promoted: 1)
110
+ expect(state[:entries].first).to include(status: 'confirmed', confidence: 0.8)
111
+ expect(state[:relations]).to contain_exactly(hash_including(from_entry_id: 1, to_entry_id: 4, relation_type: 'similar_to'))
112
+ end
113
+
114
+ it 'requires shared identity-group membership for team corroboration' do
115
+ state = corroboration_state(
116
+ entries: [candidate.merge(access_scope: 'team', identity_principal_id: 42),
117
+ confirmed.merge(access_scope: 'team', identity_principal_id: 77),
118
+ confirmed.merge(id: 3, access_scope: 'team', identity_principal_id: 99)],
119
+ memberships: [{ principal_id: 42, group_id: 7 }, { principal_id: 99, group_id: 7 }, { principal_id: 77, group_id: 8 }]
120
+ )
121
+
122
+ expect(state[:result]).to include(success: true, promoted: 1)
123
+ expect(state[:relations]).to contain_exactly(hash_including(from_entry_id: 1, to_entry_id: 3))
124
+ end
125
+
126
+ it 'does not corroborate private knowledge with an unresolved owner' do
127
+ state = corroboration_state(entries: [candidate.merge(access_scope: 'private'),
128
+ confirmed.merge(access_scope: 'private', identity_principal_id: 42)])
129
+
130
+ expect(state[:result]).to include(success: true, promoted: 0)
131
+ expect(state[:entries].first[:status]).to eq('candidate')
132
+ expect(state[:relations]).to eq([])
133
+ end
134
+
135
+ it 'does not use private evidence to promote a global entry' do
136
+ state = corroboration_state(entries: [candidate.merge(identity_principal_id: 42),
137
+ confirmed.merge(access_scope: 'private', identity_principal_id: 42)])
138
+
139
+ expect(state[:result]).to include(success: true, promoted: 0)
140
+ expect(state[:entries].first[:confidence]).to eq(0.5)
141
+ expect(state[:relations]).to eq([])
142
+ end
143
+
144
+ it 'recognizes private ownership through the existing identity-to-principal mapping' do
145
+ state = corroboration_state(
146
+ entries: [candidate.merge(access_scope: 'private', identity_principal_id: 42),
147
+ confirmed.merge(access_scope: 'private', identity_id: 70)],
148
+ identities: [{ id: 70, principal_id: 42 }]
149
+ )
150
+
151
+ expect(state[:result]).to include(success: true, promoted: 1)
152
+ expect(state[:relations]).to contain_exactly(hash_including(from_entry_id: 1, to_entry_id: 2))
153
+ end
154
+
155
+ it 'bootstraps exactly one fresh-store confirmation from independent candidate evidence' do
156
+ state = corroboration_state(
157
+ entries: [candidate, candidate.merge(id: 2, source_provider: 'claude', source_channel: 'source-b')],
158
+ runs: 2
159
+ )
160
+
161
+ expect(state[:results]).to include(hash_including(promoted: 1), hash_including(promoted: 0))
162
+ expect(state[:entries].map { |entry| entry[:status] }).to eq(%w[confirmed candidate])
163
+ expect(state[:relations]).to contain_exactly(hash_including(from_entry_id: 1, to_entry_id: 2, relation_type: 'similar_to'))
164
+ end
165
+
166
+ it 'does not bootstrap confirmation from the same provider or source channel' do
167
+ same_provider = corroboration_state(
168
+ entries: [candidate, candidate.merge(id: 2, source_channel: 'source-b')]
169
+ )
170
+ same_channel = corroboration_state(
171
+ entries: [candidate, candidate.merge(id: 2, source_provider: 'claude')]
172
+ )
173
+
174
+ expect(same_provider[:result]).to include(success: true, promoted: 0)
175
+ expect(same_channel[:result]).to include(success: true, promoted: 0)
176
+ expect(same_provider[:relations]).to eq([])
177
+ expect(same_channel[:relations]).to eq([])
178
+ end
179
+
180
+ it 'does not use candidate evidence owned by another private principal to bootstrap confirmation' do
181
+ state = corroboration_state(
182
+ entries: [candidate.merge(access_scope: 'private', identity_principal_id: 42),
183
+ candidate.merge(id: 2, access_scope: 'private', identity_principal_id: 99,
184
+ source_provider: 'claude', source_channel: 'source-b')]
185
+ )
186
+
187
+ expect(state[:result]).to include(success: true, promoted: 0)
188
+ expect(state[:entries].map { |entry| entry[:status] }).to all(eq('candidate'))
189
+ expect(state[:relations]).to eq([])
190
+ end
191
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'open3'
5
+ require 'rbconfig'
6
+ require 'json'
7
+
8
+ RSpec.describe 'Apollo GAS relation persistence' do
9
+ let(:deposit_script) do
10
+ <<~'RUBY'
11
+ require 'sequel'
12
+ require 'rspec'
13
+ require 'spec_helper'
14
+ require 'json'
15
+ require 'legion/extensions/apollo/helpers/writeback'
16
+ require 'legion/extensions/apollo/runners/gas'
17
+ db = Sequel.sqlite
18
+ db.create_table(:apollo_entries) do
19
+ primary_key :id
20
+ String :content
21
+ String :content_hash, unique: true
22
+ String :content_type
23
+ String :status
24
+ String :access_scope
25
+ Integer :identity_principal_id
26
+ Float :confidence
27
+ end
28
+ db.create_table(:apollo_relations) do
29
+ primary_key :id
30
+ foreign_key :from_entry_id, :apollo_entries, null: false
31
+ foreign_key :to_entry_id, :apollo_entries, null: false
32
+ String :relation_type
33
+ String :source_agent
34
+ Float :weight
35
+ check(Sequel.~(relation_type: 'causes'))
36
+ end
37
+ db.create_table(:apollo_expertise) { primary_key :id }
38
+ db.create_table(:apollo_access_logs) { primary_key :id }
39
+ Legion::Data.const_set(:Model, Module.new) unless defined?(Legion::Data::Model)
40
+ Legion::Data::Model.const_set(:Apollo, Module.new)
41
+ { Entry: :apollo_entries, Relation: :apollo_relations, Expertise: :apollo_expertise,
42
+ AccessLog: :apollo_access_logs }.each do |name, table|
43
+ Legion::Data::Model::Apollo.const_set(name, Class.new(Sequel::Model(db[table])))
44
+ end
45
+ input = ::JSON.parse($stdin.read, symbolize_names: true)
46
+ input[:entries].each do |entry|
47
+ db[:apollo_entries].insert(entry.merge(content_hash: Legion::Extensions::Apollo::Helpers::Writeback.content_hash(entry[:content])))
48
+ end
49
+ knowledge = Legion::Extensions::Apollo::Runners::Knowledge
50
+ knowledge.extend(knowledge)
51
+ result = Legion::Extensions::Apollo::Runners::Gas.phase_deposit(
52
+ input[:facts], [], input[:relations], [], { request_id: 'gas-regression' }
53
+ )
54
+ puts "APOLLO_RESULT=#{::JSON.generate(result: result, relations: db[:apollo_relations].all)}"
55
+ RUBY
56
+ end
57
+
58
+ def gas_deposit_state(entries:, facts:, relations:)
59
+ stdout, stderr, status = Open3.capture3(
60
+ RbConfig.ruby, '-Ilib', '-Ispec', '-e', deposit_script,
61
+ chdir: File.expand_path('../../../../..', __dir__),
62
+ stdin_data: JSON.generate(entries: entries, facts: facts, relations: relations)
63
+ )
64
+ line = stdout.lines.find { |output| output.start_with?('APOLLO_RESULT=') }
65
+ raise "GAS deposit process failed: #{stderr}\n#{stdout}" unless status.success? && line
66
+
67
+ JSON.parse(line.delete_prefix('APOLLO_RESULT='), symbolize_names: true)
68
+ end
69
+
70
+ it 'links successful deduplicated facts safely and continues after an independent relation write fails' do
71
+ entry = { content_type: 'fact', status: 'candidate', access_scope: 'global', confidence: 0.5 }
72
+ entries = [entry.merge(id: 1, content: 'stored fact'), entry.merge(id: 2, content: 'target evidence'),
73
+ entry.merge(id: 3, content: 'private evidence', access_scope: 'private', identity_principal_id: 99),
74
+ entry.merge(id: 4, content: 'second fact')]
75
+ facts = [{ content: 'stored fact', content_type: :fact }, { content: '', content_type: :fact },
76
+ { content: 'second fact', content_type: :fact }]
77
+ relation = { from_content: 'stored fact', to_id: 2, relation_type: 'supports_by', confidence: 0.9 }
78
+ relations = [relation, relation.dup, relation.merge(from_content: ''), relation.merge(to_id: 999),
79
+ relation.merge(to_id: 1), relation.merge(to_id: 3), relation.merge(confidence: 0.2),
80
+ relation.merge(relation_type: 'unsupported'), relation.merge(from_content: 'second fact', relation_type: 'causes'),
81
+ relation.merge(from_content: 'second fact', relation_type: 'depends_on')]
82
+
83
+ state = gas_deposit_state(entries: entries, facts: facts, relations: relations)
84
+
85
+ expect(state[:result]).to include(deposited: 2, failed: 1, facts: 2, synthesis: 0,
86
+ relations: 2, relations_failed: 3, relations_skipped: 5)
87
+ expect(state[:relations]).to contain_exactly(
88
+ hash_including(from_entry_id: 1, to_entry_id: 2, relation_type: 'supports_by'),
89
+ hash_including(from_entry_id: 4, to_entry_id: 2, relation_type: 'depends_on')
90
+ )
91
+ end
92
+ end
@@ -17,19 +17,6 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Gas do
17
17
  }
18
18
  end
19
19
 
20
- it 'runs all 6 phases in order' do
21
- allow(described_class).to receive(:phase_comprehend).and_return([{ content: 'fact', content_type: :fact }])
22
- allow(described_class).to receive(:phase_extract).and_return([{ name: 'pgvector', type: 'technology' }])
23
- allow(described_class).to receive(:phase_relate).and_return([])
24
- allow(described_class).to receive(:phase_synthesize).and_return([])
25
- allow(described_class).to receive(:phase_deposit).and_return({ deposited: 1 })
26
- allow(described_class).to receive(:phase_anticipate).and_return([])
27
-
28
- result = described_class.process(audit_event)
29
- expect(result).to be_a(Hash)
30
- expect(result[:phases_completed]).to eq(6)
31
- end
32
-
33
20
  it 'skips when audit event has no content' do
34
21
  result = described_class.process({ request_id: 'req_abc' })
35
22
  expect(result[:phases_completed]).to eq(0)
@@ -80,29 +67,36 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Gas do
80
67
  end
81
68
  end
82
69
 
83
- describe '.phase_relate' do
84
- it 'returns empty array (stub)' do
85
- expect(described_class.phase_relate([], [])).to eq([])
86
- end
87
- end
88
-
89
- describe '.phase_synthesize' do
90
- it 'returns empty array (stub)' do
91
- expect(described_class.phase_synthesize([], [])).to eq([])
92
- end
93
- end
70
+ describe '.phase_deposit' do
71
+ it 'reports unavailable writes as failures rather than successful deposits' do
72
+ hide_const('Legion::Extensions::Apollo::Runners::Knowledge')
73
+ result = described_class.phase_deposit([{ content: 'fact' }], [], [{ to_id: 'target' }],
74
+ [{ content: 'synthesis' }], { request_id: 'req_1' })
94
75
 
95
- describe '.phase_anticipate' do
96
- it 'returns empty array (stub)' do
97
- expect(described_class.phase_anticipate([], [])).to eq([])
76
+ expect(result).to include(deposited: 0, failed: 2, relations: 0, relations_failed: 1)
98
77
  end
99
- end
100
78
 
101
- describe '.phase_deposit' do
102
- it 'returns deposited 0 when Knowledge not defined' do
103
- hide_const('Legion::Extensions::Apollo::Runners::Knowledge') if defined?(Legion::Extensions::Apollo::Runners::Knowledge)
104
- result = described_class.phase_deposit([], [], [], [], { request_id: 'req_1' })
105
- expect(result).to eq({ deposited: 0 })
79
+ it 'counts only successful fact and synthesis writes with an entry ID' do
80
+ knowledge = double('Knowledge')
81
+ stub_const('Legion::Extensions::Apollo::Runners::Knowledge', knowledge)
82
+ allow(knowledge).to receive(:handle_ingest) do |content:, **|
83
+ case content
84
+ when 'stored fact' then { success: true, entry_id: 41 }
85
+ when 'stored synthesis' then { success: true, entry_id: 42 }
86
+ when 'missing ID' then { success: true }
87
+ else { success: false, error: 'write failed' }
88
+ end
89
+ end
90
+ facts = [{ content: 'stored fact', content_type: :fact }, { content: 'failed fact', content_type: :fact }]
91
+ synthesis = [
92
+ { content: 'stored synthesis', content_type: :inference, source_indices: [0] },
93
+ { content: 'failed synthesis', content_type: :implication, source_indices: [0] },
94
+ { content: 'missing ID', content_type: :connection, source_indices: [0] }
95
+ ]
96
+
97
+ result = described_class.phase_deposit(facts, [], [], synthesis, { request_id: 'req_1' })
98
+
99
+ expect(result).to include(deposited: 2, failed: 3, facts: 1, synthesis: 1, relations: 0)
106
100
  end
107
101
  end
108
102
  end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'open3'
5
+ require 'rbconfig'
6
+ require 'json'
7
+
8
+ RSpec.describe 'Apollo ingest transaction' do
9
+ let(:ingest_script) do
10
+ <<~'RUBY'
11
+ require 'sequel'
12
+ require 'rspec'
13
+ require 'sequel/extensions/pg_array'
14
+ require 'spec_helper'
15
+ require 'json'
16
+ require 'legion/extensions/apollo/runners/knowledge'
17
+ db = Sequel.sqlite
18
+ db.create_table(:apollo_entries) do
19
+ primary_key :id
20
+ String :content
21
+ String :content_type
22
+ Float :confidence
23
+ String :source_agent
24
+ String :source_provider
25
+ String :source_channel
26
+ String :source_context
27
+ String :tags
28
+ String :status
29
+ String :knowledge_domain
30
+ String :submitted_by
31
+ String :submitted_from
32
+ String :content_hash
33
+ String :access_scope
34
+ Integer :identity_principal_id
35
+ Integer :identity_id
36
+ String :identity_canonical_name
37
+ String :embedding
38
+ DateTime :created_at
39
+ DateTime :updated_at
40
+ check(Sequel.lit("content != 'entry failure'"))
41
+ end
42
+ db.create_table(:apollo_expertise) do
43
+ primary_key :id
44
+ String :agent_id
45
+ String :domain
46
+ Float :proficiency
47
+ Integer :entry_count
48
+ DateTime :last_active_at
49
+ check(Sequel.lit("agent_id != 'fail-expertise'"))
50
+ end
51
+ db.create_table(:apollo_access_log) do
52
+ primary_key :id
53
+ Integer :entry_id
54
+ String :agent_id
55
+ String :action
56
+ check(Sequel.lit("agent_id != 'fail-audit'"))
57
+ end
58
+ input = ::JSON.parse($stdin.read, symbolize_names: true)
59
+ Legion::Data.const_set(:Model, Module.new) unless defined?(Legion::Data::Model)
60
+ Legion::Data::Model.const_set(:Apollo, Module.new)
61
+ { Entry: :apollo_entries, Expertise: :apollo_expertise, AccessLog: :apollo_access_log }.each do |name, table|
62
+ next if name == input[:missing]&.to_sym
63
+
64
+ Legion::Data::Model::Apollo.const_set(name, Class.new(Sequel::Model(db[table])))
65
+ end
66
+ runner = Object.new.extend(Legion::Extensions::Apollo::Runners::Knowledge)
67
+ result = runner.handle_ingest(
68
+ content: input[:content], content_type: :fact, source_agent: input[:source_agent], embedding: nil
69
+ )
70
+ state = { result: result, entries: db[:apollo_entries].count,
71
+ expertise: db[:apollo_expertise].count, access_logs: db[:apollo_access_log].count }
72
+ puts "APOLLO_RESULT=#{::JSON.generate(state)}"
73
+ RUBY
74
+ end
75
+
76
+ def ingest_state(content:, source_agent: 'success', missing: nil)
77
+ stdout, stderr, status = Open3.capture3(
78
+ RbConfig.ruby, '-Ilib', '-Ispec', '-e', ingest_script,
79
+ chdir: File.expand_path('../../../../..', __dir__),
80
+ stdin_data: JSON.generate(content: content, source_agent: source_agent, missing: missing)
81
+ )
82
+ line = stdout.lines.find { |output| output.start_with?('APOLLO_RESULT=') }
83
+ raise "Ingest transaction process failed: #{stderr}\n#{stdout}" unless status.success? && line
84
+
85
+ JSON.parse(line.delete_prefix('APOLLO_RESULT='), symbolize_names: true)
86
+ end
87
+
88
+ it 'commits the entry, expertise bookkeeping, and access log as one successful ingest' do
89
+ state = ingest_state(content: 'committed knowledge')
90
+
91
+ expect(state[:result]).to include(success: true, status: 'candidate')
92
+ expect(state).to include(entries: 1, expertise: 1, access_logs: 1)
93
+ end
94
+
95
+ it 'rolls back all persistence when any synchronous ingest side effect fails' do
96
+ entry_failure = ingest_state(content: 'entry failure')
97
+ expertise_failure = ingest_state(content: 'expertise failure', source_agent: 'fail-expertise')
98
+ audit_failure = ingest_state(content: 'audit failure', source_agent: 'fail-audit')
99
+
100
+ [entry_failure, expertise_failure, audit_failure].each do |state|
101
+ expect(state[:result]).to include(success: false)
102
+ expect(state).to include(entries: 0, expertise: 0, access_logs: 0)
103
+ end
104
+ end
105
+ it 'rejects missing required auxiliary models before creating the primary entry' do
106
+ missing_expertise = ingest_state(content: 'unavailable expertise', missing: :Expertise)
107
+ missing_access_log = ingest_state(content: 'unavailable access log', missing: :AccessLog)
108
+
109
+ [missing_expertise, missing_access_log].each do |state|
110
+ expect(state[:result]).to include(success: false, error: 'apollo_data_not_available')
111
+ expect(state).to include(entries: 0, expertise: 0, access_logs: 0)
112
+ end
113
+ end
114
+ end