lex-llm-ledger 0.5.0 → 0.7.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.
Files changed (25) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/lib/legion/extensions/llm/ledger/actors/escalations.rb +34 -0
  4. data/lib/legion/extensions/llm/ledger/actors/reconciliation.rb +48 -0
  5. data/lib/legion/extensions/llm/ledger/actors/retention_purge.rb +47 -0
  6. data/lib/legion/extensions/llm/ledger/actors/skills.rb +34 -0
  7. data/lib/legion/extensions/llm/ledger/backfill/legacy_llm_records.rb +8 -2
  8. data/lib/legion/extensions/llm/ledger/helpers/caller_identity.rb +16 -2
  9. data/lib/legion/extensions/llm/ledger/helpers/retention.rb +1 -1
  10. data/lib/legion/extensions/llm/ledger/runners/escalations.rb +115 -0
  11. data/lib/legion/extensions/llm/ledger/runners/metering.rb +14 -12
  12. data/lib/legion/extensions/llm/ledger/runners/prompts.rb +19 -4
  13. data/lib/legion/extensions/llm/ledger/runners/provider_stats.rb +1 -1
  14. data/lib/legion/extensions/llm/ledger/runners/reconciliation.rb +104 -0
  15. data/lib/legion/extensions/llm/ledger/runners/retention_purge.rb +62 -0
  16. data/lib/legion/extensions/llm/ledger/runners/skills.rb +99 -0
  17. data/lib/legion/extensions/llm/ledger/runners/tools.rb +26 -20
  18. data/lib/legion/extensions/llm/ledger/transport/exchanges/escalation.rb +23 -0
  19. data/lib/legion/extensions/llm/ledger/transport/queues/audit_escalations.rb +23 -0
  20. data/lib/legion/extensions/llm/ledger/transport/queues/audit_skills.rb +23 -0
  21. data/lib/legion/extensions/llm/ledger/transport/transport.rb +11 -0
  22. data/lib/legion/extensions/llm/ledger/version.rb +1 -1
  23. data/lib/legion/extensions/llm/ledger/writers/official_record_writer.rb +134 -21
  24. data/lib/legion/extensions/llm/ledger.rb +10 -0
  25. metadata +12 -1
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/logging'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Llm
8
+ module Ledger
9
+ module Runners
10
+ module RetentionPurge
11
+ extend self
12
+ extend Legion::Logging::Helper
13
+
14
+ PURGEABLE_TABLES = %i[
15
+ llm_conversations
16
+ ].freeze
17
+
18
+ BATCH_SIZE = 500
19
+
20
+ def purge_expired
21
+ db = ::Legion::Data.connection
22
+ total_deleted = 0
23
+
24
+ PURGEABLE_TABLES.each do |table|
25
+ next unless db.table_exists?(table)
26
+
27
+ deleted = purge_table(db, table)
28
+ total_deleted += deleted
29
+ log.info("[ledger] retention_purge: deleted #{deleted} expired rows from #{table}") if deleted.positive?
30
+ end
31
+
32
+ { result: :ok, deleted: total_deleted }
33
+ rescue StandardError => e
34
+ handle_exception(e, level: :error, handled: true, operation: 'retention_purge')
35
+ { result: :error, error: e.message }
36
+ end
37
+
38
+ private
39
+
40
+ def purge_table(db, table)
41
+ deleted = 0
42
+ loop do
43
+ ids = db[table]
44
+ .where { expires_at <= Time.now.utc }
45
+ .where(Sequel.~(expires_at: nil))
46
+ .select(:id)
47
+ .limit(BATCH_SIZE)
48
+ .select_map(:id)
49
+ break if ids.empty?
50
+
51
+ db[table].where(id: ids).delete
52
+ deleted += ids.size
53
+ break if ids.size < BATCH_SIZE
54
+ end
55
+ deleted
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'securerandom'
5
+ require_relative '../helpers/caller_identity'
6
+ require_relative '../helpers/json'
7
+ require_relative '../helpers/persistence_logging'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module Llm
12
+ module Ledger
13
+ module Runners
14
+ module Skills
15
+ extend self
16
+
17
+ def write_skill_record(payload = nil, metadata = {}, **message)
18
+ payload, metadata = normalize_runner_args(payload, metadata, message)
19
+ headers = Helpers::SubscriptionMessage.extract_headers(payload, metadata)
20
+ props = metadata[:properties] || {}
21
+
22
+ body = payload.is_a?(Hash) ? payload : Helpers::Decryption.decrypt_if_needed(payload, metadata)
23
+
24
+ db = ::Legion::Data.connection
25
+ record = build_skill_record(db, body, props, headers)
26
+
27
+ Helpers::PersistenceLogging.insert_row(
28
+ db, :llm_skill_events, record,
29
+ operation: 'write_skill_record'
30
+ )
31
+ { result: :ok }
32
+ rescue Sequel::UniqueConstraintViolation => e
33
+ log.warn("write_skill_record duplicate insert ignored: #{e.message}")
34
+ { result: :duplicate }
35
+ rescue Helpers::DecryptionUnavailable => e
36
+ handle_exception(e, level: :warn, handled: true, operation: 'write_skill_record.decrypt')
37
+ raise
38
+ rescue Helpers::DecryptionFailed => e
39
+ handle_exception(e, level: :error, handled: true, operation: 'write_skill_record.decrypt')
40
+ raise
41
+ rescue StandardError => e
42
+ handle_exception(e, level: :error, handled: true, operation: 'write_skill_record')
43
+ { result: :error, error: e.message }
44
+ end
45
+
46
+ private
47
+
48
+ def normalize_runner_args(payload, metadata, message)
49
+ Helpers::SubscriptionMessage.runner_args(payload, metadata, message)
50
+ end
51
+
52
+ def build_skill_record(db, body, props, headers)
53
+ identity = Helpers::CallerIdentity.normalize(
54
+ caller_raw: body[:caller], identity: body[:identity], headers: headers
55
+ )
56
+ skill = body[:skill] || {}
57
+
58
+ {
59
+ uuid: stable_uuid(props[:message_id] || body[:event_id] || SecureRandom.uuid),
60
+ conversation_id: resolve_conversation_id(db, body, headers),
61
+ request_ref: body[:request_id] || props[:correlation_id],
62
+ skill_name: skill[:name] || body[:skill_name],
63
+ skill_version: skill[:version],
64
+ trigger: skill[:trigger] || body[:trigger],
65
+ status: body[:status] || 'completed',
66
+ duration_ms: body[:duration_ms].to_i,
67
+ identity_canonical_name: identity[:identity],
68
+ identity_principal_id: identity[:principal_id],
69
+ identity_id: identity[:identity_id],
70
+ recorded_at: body[:recorded_at] || body[:timestamp] || Time.now.utc,
71
+ inserted_at: Time.now.utc
72
+ }
73
+ end
74
+
75
+ def resolve_conversation_id(db, body, headers)
76
+ conv_ref = body[:conversation_id] || headers['x-legion-llm-conversation-id']
77
+ return nil unless conv_ref # rubocop:disable Legion/Extension/RunnerReturnHash
78
+
79
+ conv = db[:llm_conversations].where(uuid: stable_uuid(conv_ref)).first ||
80
+ db[:llm_conversations].where(uuid: conv_ref).first
81
+ conv&.[](:id)
82
+ end
83
+
84
+ def stable_uuid(value)
85
+ raw = value.to_s
86
+ return raw if raw.length <= 36 # rubocop:disable Legion/Extension/RunnerReturnHash
87
+
88
+ hex = Digest::SHA256.hexdigest(raw)[0, 32]
89
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
90
+ end
91
+
92
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
93
+ Legion::Extensions::Helpers.const_defined?(:Lex, false)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -23,11 +23,6 @@ module Legion
23
23
  ctx = body[:message_context] || {}
24
24
  tool = body[:tool_call] || {}
25
25
 
26
- Helpers::Retention.resolve(
27
- retention: headers['x-legion-retention'],
28
- contains_phi: headers['x-legion-contains-phi'] == 'true'
29
- )
30
-
31
26
  db = ::Legion::Data.connection
32
27
  response = find_or_resolve_response_with_retry(db, body, ctx, props, headers)
33
28
  write_result = [:ok]
@@ -125,6 +120,8 @@ module Legion
125
120
  status = tool[:status] || headers['x-legion-tool-status'] || 'success'
126
121
  ts = body[:timestamps] || {}
127
122
 
123
+ result_value = tool[:result] || body[:result]
124
+ has_result = tool[:result] || body.key?(:result)
128
125
  id = insert_with_savepoint(db, :llm_tool_calls, {
129
126
  uuid: tool_uuid,
130
127
  message_inference_response_id: response_id,
@@ -135,6 +132,12 @@ module Legion
135
132
  tool_source_type: src[:type] || headers['x-legion-tool-source-type'],
136
133
  tool_source_server: src[:server] || headers['x-legion-tool-source-server'],
137
134
  status: status,
135
+ tool_arguments_json: tool[:arguments] ? Helpers::Json.dump(tool[:arguments]) : nil,
136
+ tool_result_json: has_result ? Helpers::Json.dump(result_value) : nil,
137
+ tool_category: tool[:category] || tool[:tool_category],
138
+ data_handling_classification: tool[:data_handling_classification],
139
+ policy_decision: tool[:policy_decision],
140
+ requires_human_approval: tool[:requires_human_approval],
138
141
  requested_at: ts[:tool_start] || tool[:started_at],
139
142
  completed_at: ts[:tool_end] || tool[:finished_at],
140
143
  **identity_attrs,
@@ -149,7 +152,7 @@ module Legion
149
152
  [row, false]
150
153
  end
151
154
 
152
- def find_or_create_tool_call_attempt(db, tool_call_row, tool, body, props, headers, identity_attrs) # rubocop:disable Metrics/CyclomaticComplexity
155
+ def find_or_create_tool_call_attempt(db, tool_call_row, tool, body, props, headers, identity_attrs)
153
156
  return nil unless tool_call_row # rubocop:disable Legion/Extension/RunnerReturnHash
154
157
 
155
158
  tool_call_id = tool_call_row[:id]
@@ -167,21 +170,24 @@ module Legion
167
170
  runner_ref = body[:worker_id] || body[:runner_ref] || props[:app_id]
168
171
 
169
172
  id = insert_with_savepoint(db, :llm_tool_call_attempts, {
170
- uuid: attempt_uuid,
171
- tool_call_id: tool_call_id,
172
- attempt_no: attempt_no,
173
- runner_ref: runner_ref,
174
- status: status,
175
- error_category: error_hash[:category] || error_hash[:type],
176
- error_code: error_hash[:code],
177
- error_message: error_info.is_a?(String) ? error_info : error_hash[:message],
178
- duration_ms: tool[:duration_ms].to_i,
179
- arguments_ref: sha256_ref(tool[:arguments]),
180
- result_ref: sha256_ref(tool[:result] || body[:result]),
181
- started_at: ts[:tool_start] || tool[:started_at],
182
- ended_at: ts[:tool_end] || tool[:finished_at],
173
+ uuid: attempt_uuid,
174
+ tool_call_id: tool_call_id,
175
+ attempt_no: attempt_no,
176
+ runner_ref: runner_ref,
177
+ status: status,
178
+ error_category: error_hash[:category] || error_hash[:type],
179
+ error_code: error_hash[:code],
180
+ error_message: error_info.is_a?(String) ? error_info : error_hash[:message],
181
+ duration_ms: tool[:duration_ms].to_i,
182
+ arguments_ref: sha256_ref(tool[:arguments]),
183
+ result_ref: sha256_ref(tool[:result] || body[:result]),
184
+ attempt_input_json: tool[:arguments] ? Helpers::Json.dump(tool[:arguments]) : nil,
185
+ attempt_output_json: Helpers::Json.dump(tool[:result] || body[:result]),
186
+ error_details_json: tool[:error] ? Helpers::Json.dump(tool[:error]) : nil,
187
+ started_at: ts[:tool_start] || tool[:started_at],
188
+ ended_at: ts[:tool_end] || tool[:finished_at],
183
189
  **identity_attrs,
184
- inserted_at: Time.now.utc
190
+ inserted_at: Time.now.utc
185
191
  }, operation: 'write_tool_record.attempt')
186
192
  db[:llm_tool_call_attempts][id: id]
187
193
  rescue Sequel::UniqueConstraintViolation => e
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Ledger
7
+ module Transport
8
+ module Exchanges
9
+ class Escalation < ::Legion::Transport::Exchange
10
+ def exchange_name
11
+ 'llm.escalation'
12
+ end
13
+
14
+ def default_type
15
+ 'topic'
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Ledger
7
+ module Transport
8
+ module Queues
9
+ class AuditEscalations < Legion::Transport::Queue
10
+ def queue_name
11
+ 'llm.audit.escalations'
12
+ end
13
+
14
+ def queue_options
15
+ { durable: true }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Ledger
7
+ module Transport
8
+ module Queues
9
+ class AuditSkills < Legion::Transport::Queue
10
+ def queue_name
11
+ 'llm.audit.skills'
12
+ end
13
+
14
+ def queue_options
15
+ { durable: true }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -3,6 +3,7 @@
3
3
  require 'legion/extensions/transport'
4
4
  require_relative 'exchanges/metering'
5
5
  require_relative 'exchanges/audit'
6
+ require_relative 'exchanges/escalation'
6
7
  require_relative 'exchanges/registry'
7
8
 
8
9
  module Legion
@@ -29,6 +30,16 @@ module Legion
29
30
  to: Legion::Extensions::Llm::Ledger::Transport::Queues::AuditTools,
30
31
  routing_key: 'audit.tool.#'
31
32
  },
33
+ {
34
+ from: Legion::Extensions::Llm::Ledger::Transport::Exchanges::Audit,
35
+ to: Legion::Extensions::Llm::Ledger::Transport::Queues::AuditSkills,
36
+ routing_key: 'audit.skill.#'
37
+ },
38
+ {
39
+ from: Legion::Extensions::Llm::Ledger::Transport::Exchanges::Escalation,
40
+ to: Legion::Extensions::Llm::Ledger::Transport::Queues::AuditEscalations,
41
+ routing_key: '#'
42
+ },
32
43
  {
33
44
  from: Legion::Extensions::Llm::Ledger::Transport::Exchanges::Registry,
34
45
  to: Legion::Extensions::Llm::Ledger::Transport::Queues::RegistryAvailability,
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Ledger
7
- VERSION = '0.5.0'
7
+ VERSION = '0.7.0'
8
8
  end
9
9
  end
10
10
  end
@@ -63,6 +63,8 @@ module Legion
63
63
  classification_level: classification_level(body),
64
64
  contains_phi: contains_phi?(body),
65
65
  contains_pii: contains_pii?(body),
66
+ pii_types_json: json_dump(Array(body.dig(:classification, :pii_types))),
67
+ jurisdictions_json: json_dump(Array(body.dig(:classification, :jurisdictions) || body[:jurisdictions])),
66
68
  retention_policy: body[:retention_policy] || 'default',
67
69
  expires_at: body[:expires_at],
68
70
  identity_canonical_name: identity_canonical_name(body),
@@ -121,6 +123,7 @@ module Legion
121
123
  uuid: stable_uuid(request_id),
122
124
  conversation_id: conversation[:id],
123
125
  latest_message_id: latest_message&.dig(:id),
126
+ parent_request_id: resolve_parent_request_id(db, body),
124
127
  caller_principal_id: caller_refs[:principal_id],
125
128
  caller_identity_id: caller_refs[:identity_id],
126
129
  identity_canonical_name: identity_canonical_name(body),
@@ -137,10 +140,18 @@ module Legion
137
140
  status: 'responded',
138
141
  context_message_count: Array(body.dig(:request, :messages) || body[:messages]).size,
139
142
  request_capture_mode: 'full',
140
- request_json: json_dump(request_payload(body)),
143
+ request_json: if request_payload(body)
144
+ phi_protect(json_dump(request_payload(body)),
145
+ contains_phi?(body))
146
+ end,
141
147
  classification_level: classification_level(body),
142
148
  cost_center: billing(body)[:cost_center],
143
149
  budget_key: billing(body)[:budget_id] || billing(body)[:budget_key],
150
+ injected_tool_count: Array(body.dig(:audit, :injected_tools) || body[:injected_tools]).size,
151
+ context_tokens: resolve_context_tokens(body),
152
+ request_content_hash: compute_content_hash(body.dig(:request, :content) || body.dig(:audit, :request_content)),
153
+ curation_strategy: body[:curation_strategy] || body.dig(:audit, :curation_strategy),
154
+ tool_policy: body[:tool_policy] || body.dig(:audit, :tool_policy),
144
155
  requested_at: recorded_at(body),
145
156
  inserted_at: Time.now.utc
146
157
  }, operation: 'official_record_writer.inference_request')
@@ -187,13 +198,28 @@ module Legion
187
198
  end
188
199
 
189
200
  def find_or_create_response(db, request, response_message, body)
190
- response_uuid = stable_uuid(reference(body, :provider_response_ref) || "response:#{request_ref(body)}")
201
+ response_uuid = stable_uuid(reference(body, :provider_response_ref) || "response:#{request_ref(body)}:#{body[:provider] || 'unknown'}")
191
202
  existing = db[:llm_message_inference_responses].where(uuid: response_uuid).first
203
+
204
+ # Fallback: if we couldn't find a response by UUID, check if a response
205
+ # already exists for this request (e.g., metering arrived first and created
206
+ # a response with a different UUID). Enrich it instead of creating a duplicate.
207
+ unless existing
208
+ existing = db[:llm_message_inference_responses]
209
+ .where(message_inference_request_id: request[:id])
210
+ .first
211
+ log.debug("[ledger] response fallback: found existing response id=#{existing[:id]} for request_id=#{request[:id]}") if existing
212
+ end
213
+
192
214
  if existing
193
215
  enrich_response!(db, existing, response_message, body)
194
216
  return existing
195
217
  end
196
218
 
219
+ vis_resp = visible_response(body)
220
+ think_resp = thinking_response(body)
221
+ phi = contains_phi?(body)
222
+
197
223
  id = insert_with_savepoint(db, :llm_message_inference_responses, {
198
224
  uuid: response_uuid,
199
225
  message_inference_request_id: request[:id],
@@ -209,9 +235,15 @@ module Legion
209
235
  latency_ms: integer(body[:latency_ms]),
210
236
  wall_clock_ms: integer(body[:wall_clock_ms]),
211
237
  response_capture_mode: 'full',
212
- response_json: json_dump(visible_response(body)),
213
- response_thinking_json: json_dump(thinking_response(body)),
238
+ response_json: vis_resp ? phi_protect(json_dump(vis_resp), phi) : nil,
239
+ response_thinking_json: think_resp ? phi_protect(json_dump(think_resp), phi) : nil,
214
240
  dispatch_path: body[:dispatch_path] || body[:tier],
241
+ error_category: body[:error_category] || body.dig(:error, :category),
242
+ error_code: body[:error_code] || body.dig(:error, :code),
243
+ error_message: body[:error_message] || body.dig(:error, :message),
244
+ response_content_hash: compute_content_hash(body[:response_content] || body.dig(:audit, :response_content)),
245
+ route_attempts: (body[:route_attempts] || body.dig(:audit, :route_attempts)).to_i,
246
+ escalation_chain_ref: body[:escalation_chain_ref],
215
247
  identity_principal_id: caller_identity_refs(db, body)[:principal_id],
216
248
  identity_id: caller_identity_refs(db, body)[:identity_id],
217
249
  identity_canonical_name: identity_canonical_name(body),
@@ -239,11 +271,21 @@ module Legion
239
271
  update_if_missing(updates, existing, :dispatch_path, body[:dispatch_path] || body[:tier])
240
272
  update_if_missing(updates, existing, :identity_canonical_name, identity_canonical_name(body))
241
273
 
242
- response_json = json_dump(visible_response(body))
243
- update_if_placeholder(updates, existing, :response_json, response_json)
274
+ vis = visible_response(body)
275
+ if vis
276
+ response_json = json_dump(vis)
277
+ update_if_placeholder(updates, existing, :response_json, response_json)
278
+ elsif existing[:response_json].nil?
279
+ # Nothing to add, but also don't overwrite existing data with nil
280
+ end
244
281
 
245
- thinking_json = json_dump(thinking_response(body))
246
- update_if_placeholder(updates, existing, :response_thinking_json, thinking_json)
282
+ think = thinking_response(body)
283
+ if think
284
+ thinking_json = json_dump(think)
285
+ update_if_placeholder(updates, existing, :response_thinking_json, thinking_json)
286
+ elsif existing[:response_thinking_json].nil?
287
+ # Nothing to add
288
+ end
247
289
 
248
290
  return if updates.empty?
249
291
 
@@ -251,12 +293,27 @@ module Legion
251
293
  log.info("[ledger] enriched response id=#{existing[:id]} fields=#{updates.keys.join(',')}")
252
294
  end
253
295
 
296
+ # Core guard: never upsert a value that is nil, empty string, or empty JSON object.
297
+ # This prevents a leaner message (e.g., metering) from overwriting valid data
298
+ # written by a richer message (e.g., prompt audit).
299
+ def upsert_guard?(value)
300
+ return false if value.nil?
301
+ return false if value.is_a?(String) && value.strip.empty?
302
+ return false if value.to_s == '{}'
303
+
304
+ true
305
+ end
306
+
254
307
  def update_if_missing(updates, existing, key, value)
255
- updates[key] = value if existing[key].nil? && present?(value)
308
+ updates[key] = value if existing[key].nil? && upsert_guard?(value)
256
309
  end
257
310
 
258
311
  def update_if_placeholder(updates, existing, key, value)
259
- updates[key] = value if existing[key].to_s == '{}' && value != '{}'
312
+ return unless upsert_guard?(value)
313
+
314
+ existing_val = existing[key]
315
+ is_placeholder = ['{}', 'null'].include?(existing_val.to_s)
316
+ updates[key] = value if is_placeholder
260
317
  end
261
318
 
262
319
  def find_or_create_metric(db, request, response, body)
@@ -324,15 +381,19 @@ module Legion
324
381
  updates = {}
325
382
  update_if_missing(updates, existing, :latest_message_id, latest_message&.dig(:id))
326
383
  caller_refs = caller_identity_refs(db, body)
327
- updates[:caller_identity_id] = caller_refs[:identity_id] if existing[:caller_identity_id].nil? && caller_refs[:identity_id]
328
- updates[:caller_principal_id] = caller_refs[:principal_id] if existing[:caller_principal_id].nil? && caller_refs[:principal_id]
329
- updates[:runtime_caller_type] = caller_type(body) if existing[:runtime_caller_type].nil? && caller_type(body)
384
+ update_if_missing(updates, existing, :caller_identity_id, caller_refs[:identity_id])
385
+ update_if_missing(updates, existing, :caller_principal_id, caller_refs[:principal_id])
386
+ update_if_missing(updates, existing, :runtime_caller_type, caller_type(body))
330
387
  update_if_missing(updates, existing, :runtime_caller_class, runtime_caller_class(body))
331
388
  update_if_missing(updates, existing, :runtime_caller_client, runtime_caller_client(body))
332
389
  update_if_missing(updates, existing, :identity_canonical_name, identity_canonical_name(body))
333
390
 
334
- request_json = json_dump(request_payload(body))
335
- updates[:request_json] = request_json if existing[:request_json].to_s == '{}' && request_json != '{}'
391
+ request_json = request_payload(body) ? json_dump(request_payload(body)) : nil
392
+ if request_json
393
+ update_if_placeholder(updates, existing, :request_json, request_json)
394
+ elsif existing[:request_json].nil?
395
+ # Nothing to add
396
+ end
336
397
 
337
398
  msg_count = Array(body.dig(:request, :messages) || body[:messages]).size
338
399
  updates[:context_message_count] = msg_count if existing[:context_message_count].to_i.zero? && msg_count.positive?
@@ -377,6 +438,10 @@ module Legion
377
438
  explicit_identity_id = integer_or_nil(body[:caller_identity_id] || body.dig(:caller, :requested_by, :id))
378
439
  explicit_principal_id = integer_or_nil(body[:caller_principal_id] ||
379
440
  body.dig(:caller, :requested_by, :principal_id))
441
+
442
+ explicit_identity_id ||= integer_or_nil(body[:__header_identity_id])
443
+ explicit_principal_id ||= integer_or_nil(body[:__header_principal_id])
444
+
380
445
  refs = { principal_id: explicit_principal_id, identity_id: explicit_identity_id }.compact
381
446
  unless refs[:principal_id] && refs[:identity_id]
382
447
  if explicit_identity_id && !explicit_principal_id && identity_tables_available?(db)
@@ -554,6 +619,18 @@ module Legion
554
619
  int.positive? ? int : nil
555
620
  end
556
621
 
622
+ def resolve_parent_request_id(db, body)
623
+ parent_ref = body[:parent_request_id] || body.dig(:context, :parent_request_id) || body.dig(:caller, :parent_request_ref)
624
+ return nil unless present?(parent_ref)
625
+
626
+ if parent_ref.is_a?(Integer)
627
+ parent_ref
628
+ else
629
+ parent = db[:llm_message_inference_requests].where(request_ref: parent_ref.to_s).first
630
+ parent&.dig(:id)
631
+ end
632
+ end
633
+
557
634
  def correlation_id(body)
558
635
  reference(body, :correlation_id, :correlation_ref) || body.dig(:tracing, :correlation_id)
559
636
  end
@@ -599,7 +676,7 @@ module Legion
599
676
  end
600
677
 
601
678
  def request_payload(body)
602
- body[:request] || body[:messages] || {}
679
+ body[:request] || body[:messages]
603
680
  end
604
681
 
605
682
  def request_content(body)
@@ -610,7 +687,9 @@ module Legion
610
687
  end
611
688
 
612
689
  def visible_response(body)
613
- response = body[:response] || body[:response_content] || body[:content] || {}
690
+ response = body[:response] || body[:response_content] || body[:content]
691
+ return nil if response.nil? || (response.is_a?(Hash) && response.empty?)
692
+
614
693
  if response.is_a?(String)
615
694
  clean, _thinking = extract_inline_thinking(response)
616
695
  return { content: clean }
@@ -630,10 +709,10 @@ module Legion
630
709
  end
631
710
 
632
711
  content_str = body[:response_content] || body[:response] || body[:content]
633
- return {} unless content_str.is_a?(String)
712
+ return nil unless content_str.is_a?(String)
634
713
 
635
714
  _clean, extracted = extract_inline_thinking(content_str)
636
- extracted ? { content: extracted } : {}
715
+ extracted ? { content: extracted } : nil
637
716
  end
638
717
 
639
718
  def extract_inline_thinking(text)
@@ -646,7 +725,10 @@ module Legion
646
725
  end
647
726
 
648
727
  def response_content(body)
649
- stringify_content(visible_response(body)[:content] || visible_response(body).dig(:message, :content))
728
+ vis = visible_response(body)
729
+ return nil unless vis
730
+
731
+ stringify_content(vis[:content] || vis.dig(:message, :content))
650
732
  end
651
733
 
652
734
  def finish_reason(body)
@@ -656,8 +738,14 @@ module Legion
656
738
  body.dig(:response, :finish_reason) || body.dig(:response, :stop, :reason)
657
739
  end
658
740
 
741
+ ALLOWED_CLASSIFICATION_LEVELS = %w[public internal confidential restricted].freeze
742
+
659
743
  def classification_level(body)
660
- body[:classification_level] || body.dig(:classification, :level)
744
+ raw = body[:classification_level] || body.dig(:classification, :level)
745
+ return 'internal' if raw.nil? || raw.to_s.empty?
746
+
747
+ normalized = raw.to_s.downcase
748
+ ALLOWED_CLASSIFICATION_LEVELS.include?(normalized) ? normalized : 'internal'
661
749
  end
662
750
 
663
751
  def contains_phi?(body)
@@ -706,6 +794,19 @@ module Legion
706
794
  json_dump(content)
707
795
  end
708
796
 
797
+ def phi_protect(json_string, is_phi)
798
+ return json_string unless is_phi && crypt_available?
799
+
800
+ Legion::Crypt.encrypt(json_string)
801
+ rescue StandardError => e
802
+ handle_exception(e, level: :warn, handled: true, operation: 'official_record_writer.phi_encrypt')
803
+ json_string
804
+ end
805
+
806
+ def crypt_available?
807
+ defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:encrypt)
808
+ end
809
+
709
810
  def json_dump(value)
710
811
  Helpers::Json.dump(value)
711
812
  end
@@ -728,6 +829,18 @@ module Legion
728
829
  def present?(value)
729
830
  !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
730
831
  end
832
+
833
+ def compute_content_hash(content)
834
+ return nil if content.nil? || content.to_s.empty?
835
+
836
+ Digest::SHA256.hexdigest(json_dump(content))[0..31]
837
+ end
838
+
839
+ def resolve_context_tokens(body)
840
+ raw = body[:tokens] || body[:audit] || body
841
+ val = raw[:input_tokens] || raw[:input] || raw[:context_tokens] || raw[:prompt_tokens]
842
+ present?(val) ? val.to_i : 0
843
+ end
731
844
  end
732
845
  end
733
846
  end