lex-llm-ledger 0.5.0 → 0.6.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 +29 -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 +96 -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 +27 -21
  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 +71 -7
  24. data/lib/legion/extensions/llm/ledger.rb +10 -0
  25. metadata +12 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7432d720f451a54b2826adc2eb2fa6335efb0d05cf993d2b84d29c4048090d4
4
- data.tar.gz: a77af8bd22c3e4438b5a2c1e5ba684eb042b9612aff691fcc7085a5078ee752a
3
+ metadata.gz: 70deb6cfb3814da49ebbc893a2025e06ddcf96ee7789a65f794fa66910502f41
4
+ data.tar.gz: e0837642064bf0c1ee6bf480704d297f961144f5f6cfb0814b88d8de438bf0f6
5
5
  SHA512:
6
- metadata.gz: 0b03a48cfd7912f193ce78263d44cc1ca996f5d8289f971f836a9e8231b2630bd61d4ece62d1489c8fdc92414174e4e6275df088585d0a2763496e7ff673cff5
7
- data.tar.gz: 287814a7495a4f10ba0a95f4b2f2206fadbc13bd3b6143173477dccf276ae2d15658b88e273e94abed67d0734eb9d91fee085c7b088bf7a8705e09b9e4b8e03e
6
+ metadata.gz: '024169e420f778950b01bd074b03b1e63ccc642cb6c0148a14046dc91a74426c8a4d202a70fb33b6a669fbe3248fba62940e72a3a63ba1d776cef0044e0b9e36'
7
+ data.tar.gz: 207e66a936b63bf8c52437d2dc1d92931afdcb12caea03976b4e293bf8e67d43f963e62e9ba336c1e80789b3d60ad960b56eb6b85081f3146509f84c760aaf6f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] - 2026-05-31
4
+
5
+ ### Added
6
+ - **Skills audit actor** — new queue `llm.audit.skills`, actor, and runner consuming `audit.skill.#` events from the `llm.audit` exchange. Creates `llm_skill_events` records.
7
+ - **Escalation audit actor** — new exchange `llm.escalation` binding, queue `llm.audit.escalations`, actor, and runner. Creates `llm_escalation_events` records.
8
+ - **Retention purge actor** — actively deletes expired records (hourly, batched). Enforces `session_only` and PHI TTL retention policies that were previously passive.
9
+ - **Reconciliation actor** — links orphaned tool calls (null `response_id`) and metering requests (null `latest_message_id`) within a 5-minute lookback window every 2 minutes.
10
+ - **Migration 013** — `parent_request_id` FK on requests, `schema_version` on all official tables, `pii_types_json` and `jurisdictions_json` on conversations.
11
+ - **Migration 014** — creates `llm_skill_events` and `llm_escalation_events` tables.
12
+ - **PHI field-level encryption** — `request_json`, `response_json`, `response_thinking_json` encrypted at rest via `Legion::Crypt` when `contains_phi: true`.
13
+
14
+ ### Fixed
15
+ - **LEDGER-01**: `session_only` retention maps to 0 days (immediate expiry) instead of `nil`, distinguishing it from `permanent`.
16
+ - **IDENTITY-05**: `CallerIdentity.normalize` recognizes transport identity headers (`x-legion-identity-canonical-name`, `x-legion-identity-kind`, `x-legion-identity-db-*`).
17
+ - **IDENTITY-06**: `OfficialRecordWriter.caller_identity_refs` falls back to pre-resolved AMQP header IDs.
18
+ - **GAP-06**: `ProviderStats#health_report` uses `recorded_at` instead of `inserted_at` for 24h window.
19
+ - **GAP-10**: Backfill `metering_payload` maps `provider_instance` from correct column.
20
+ - **GAP-11**: Backfill `registry_reason` extracts from metadata JSON instead of storing raw blob.
21
+ - Dead `Retention.resolve` call removed from tools runner.
22
+
23
+ ### Changed
24
+ - **Jurisdictions** stored as JSON array instead of comma-joined string.
25
+ - **Classification level** validated against controlled vocabulary (`public`/`internal`/`confidential`/`restricted`), defaults to `internal`.
26
+ - **Schema version** written to all official table inserts (constant `SCHEMA_VERSION = 13`).
27
+ - All CallerIdentity-resolved payloads now pass `__header_principal_id`/`__header_identity_id` for direct FK resolution.
28
+
29
+ ### Requires
30
+ - legion-data >= 1.8.9 (migrations 013-014)
31
+
3
32
  ## [0.5.0] - 2026-05-26
4
33
 
5
34
  ### Changed
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/subscription'
4
+ require_relative '../helpers/subscription_actor'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Llm
9
+ module Ledger
10
+ module Actor
11
+ class Escalations < Legion::Extensions::Actors::Subscription
12
+ include Helpers::SubscriptionActor
13
+
14
+ prefetch 1
15
+
16
+ def runner_class = Legion::Extensions::Llm::Ledger::Runners::Escalations
17
+
18
+ def runner_function
19
+ 'write_escalation_record'
20
+ end
21
+
22
+ def use_runner?
23
+ false
24
+ end
25
+
26
+ def queue
27
+ Legion::Extensions::Llm::Ledger::Transport::Queues::AuditEscalations
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Ledger
7
+ module Actor
8
+ class Reconciliation < Legion::Extensions::Actors::Every # rubocop:disable Legion/Extension/EveryActorRequiresTime
9
+ def runner_class
10
+ 'Legion::Extensions::Llm::Ledger::Runners::Reconciliation'
11
+ end
12
+
13
+ def runner_function
14
+ 'link_orphaned_tool_calls'
15
+ end
16
+
17
+ def time
18
+ 120
19
+ end
20
+
21
+ def run
22
+ Runners::Reconciliation.link_orphaned_tool_calls
23
+ Runners::Reconciliation.link_metering_messages
24
+ rescue StandardError => e
25
+ handle_exception(e, level: :warn, handled: true, operation: 'reconciliation')
26
+ end
27
+
28
+ def run_now?
29
+ false
30
+ end
31
+
32
+ def use_runner?
33
+ false
34
+ end
35
+
36
+ def check_subtask?
37
+ false
38
+ end
39
+
40
+ def generate_task?
41
+ false
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Ledger
7
+ module Actor
8
+ class RetentionPurge < Legion::Extensions::Actors::Every # rubocop:disable Legion/Extension/EveryActorRequiresTime
9
+ def runner_class
10
+ 'Legion::Extensions::Llm::Ledger::Runners::RetentionPurge'
11
+ end
12
+
13
+ def runner_function
14
+ 'purge_expired'
15
+ end
16
+
17
+ def time
18
+ 3600
19
+ end
20
+
21
+ def run
22
+ Runners::RetentionPurge.purge_expired
23
+ rescue StandardError => e
24
+ handle_exception(e, level: :warn, handled: true, operation: 'retention_purge')
25
+ end
26
+
27
+ def run_now?
28
+ false
29
+ end
30
+
31
+ def use_runner?
32
+ false
33
+ end
34
+
35
+ def check_subtask?
36
+ false
37
+ end
38
+
39
+ def generate_task?
40
+ false
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/subscription'
4
+ require_relative '../helpers/subscription_actor'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Llm
9
+ module Ledger
10
+ module Actor
11
+ class Skills < Legion::Extensions::Actors::Subscription
12
+ include Helpers::SubscriptionActor
13
+
14
+ prefetch 1
15
+
16
+ def runner_class = Legion::Extensions::Llm::Ledger::Runners::Skills
17
+
18
+ def runner_function
19
+ 'write_skill_record'
20
+ end
21
+
22
+ def use_runner?
23
+ false
24
+ end
25
+
26
+ def queue
27
+ Legion::Extensions::Llm::Ledger::Transport::Queues::AuditSkills
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -110,7 +110,8 @@ module Legion
110
110
  exchange_id: row[:exchange_id],
111
111
  operation: row[:request_type],
112
112
  provider: row[:provider],
113
- provider_instance: row[:worker_id],
113
+ provider_instance: row[:provider_instance],
114
+ worker_id: row[:worker_id],
114
115
  model_id: row[:model_id],
115
116
  tier: row[:tier],
116
117
  input_tokens: row[:input_tokens],
@@ -161,7 +162,7 @@ module Legion
161
162
  model_key: row[:model_id],
162
163
  event_type: row[:event_type],
163
164
  status: registry_status(row),
164
- reason: row[:metadata_json],
165
+ reason: registry_reason(row),
165
166
  recorded_at: row[:occurred_at],
166
167
  inserted_at: Time.now.utc
167
168
  }, operation: 'legacy_llm_backfill.registry_event')
@@ -197,6 +198,11 @@ module Legion
197
198
  health[:status] || health['status'] || row[:event_type] || 'unknown'
198
199
  end
199
200
 
201
+ def registry_reason(row)
202
+ metadata = json_load(row[:metadata_json])
203
+ metadata[:reason] || metadata['reason'] || metadata[:message] || metadata['message'] || row[:event_type]
204
+ end
205
+
200
206
  def table_present?(table)
201
207
  db.table_exists?(table)
202
208
  end
@@ -18,12 +18,16 @@ module Legion
18
18
  extension = hash_value(caller_hash, :extension)
19
19
  type = first_present(
20
20
  hash_value(identity_hash, :type),
21
+ hash_value(identity_hash, :kind),
22
+ header_value(headers, 'x-legion-identity-kind'),
21
23
  header_value(headers, 'x-legion-caller-type'),
22
24
  hash_value(caller, :type),
25
+ hash_value(caller, :kind),
23
26
  extension && 'extension'
24
27
  )
25
28
 
26
29
  raw_identity = first_present(
30
+ header_value(headers, 'x-legion-identity-canonical-name'),
27
31
  hash_value(identity_hash, :id),
28
32
  hash_value(identity_hash, :canonical_name),
29
33
  hash_value(identity_hash, :identity),
@@ -38,8 +42,10 @@ module Legion
38
42
  )
39
43
 
40
44
  {
41
- identity: normalize_identity_value(raw_identity, type),
42
- type: type
45
+ identity: normalize_identity_value(raw_identity, type),
46
+ type: type,
47
+ principal_id: integer_header(headers, 'x-legion-identity-db-principal-id'),
48
+ identity_id: integer_header(headers, 'x-legion-identity-db-identity-id')
43
49
  }.compact
44
50
  end
45
51
 
@@ -71,6 +77,14 @@ module Legion
71
77
  headers[key] || headers[key.to_sym]
72
78
  end
73
79
 
80
+ def integer_header(headers, key)
81
+ raw = header_value(headers, key)
82
+ return nil if raw.nil?
83
+
84
+ int = raw.to_i
85
+ int.positive? ? int : nil
86
+ end
87
+
74
88
  def present?(value)
75
89
  !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
76
90
  end
@@ -13,7 +13,7 @@ module Legion
13
13
  PHI_TTL_DEFAULT_DAYS = 30
14
14
 
15
15
  RETENTION_MAP = {
16
- 'session_only' => nil,
16
+ 'session_only' => 0,
17
17
  'days_30' => 30,
18
18
  'days_90' => 90,
19
19
  'permanent' => nil
@@ -0,0 +1,115 @@
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 Escalations
15
+ extend self
16
+
17
+ def write_escalation_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_escalation_record(db, body, props, headers)
26
+
27
+ Helpers::PersistenceLogging.insert_row(
28
+ db, :llm_escalation_events, record,
29
+ operation: 'write_escalation_record'
30
+ )
31
+ { result: :ok }
32
+ rescue Sequel::UniqueConstraintViolation => e
33
+ log.warn("write_escalation_record duplicate insert ignored: #{e.message}")
34
+ { result: :duplicate }
35
+ rescue Helpers::DecryptionUnavailable => e
36
+ handle_exception(e, level: :warn, handled: true, operation: 'write_escalation_record.decrypt')
37
+ raise
38
+ rescue Helpers::DecryptionFailed => e
39
+ handle_exception(e, level: :error, handled: true, operation: 'write_escalation_record.decrypt')
40
+ raise
41
+ rescue StandardError => e
42
+ handle_exception(e, level: :error, handled: true, operation: 'write_escalation_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_escalation_record(db, body, props, headers) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
53
+ history = Array(body[:history])
54
+ identity = Helpers::CallerIdentity.normalize(
55
+ caller_raw: body[:caller], identity: body[:identity], headers: headers
56
+ )
57
+
58
+ first_attempt = history.first || {}
59
+ last_attempt = history.last || {}
60
+
61
+ {
62
+ uuid: stable_uuid(props[:message_id] || body[:event_id] || SecureRandom.uuid),
63
+ conversation_id: resolve_conversation_id(db, body, headers),
64
+ request_ref: body[:request_id] || props[:correlation_id],
65
+ from_provider: first_attempt[:provider] || body[:from_provider],
66
+ from_instance: first_attempt[:instance] || body[:from_instance],
67
+ from_model: first_attempt[:model] || body[:from_model],
68
+ to_provider: last_attempt[:provider] || body[:to_provider],
69
+ to_instance: last_attempt[:instance] || body[:to_instance],
70
+ to_model: last_attempt[:model] || body[:to_model],
71
+ reason: body[:reason] || (first_attempt[:outcome] == 'failure' ? 'provider_failover' : nil),
72
+ error_category: body[:error_category] || extract_error_category(first_attempt),
73
+ attempt_no: history.size || (body[:attempt_no] || 1),
74
+ latency_ms: history.sum { |a| (a[:duration_ms] || 0).to_i } || body[:latency_ms].to_i,
75
+ identity_canonical_name: identity[:identity],
76
+ identity_principal_id: identity[:principal_id],
77
+ identity_id: identity[:identity_id],
78
+ history_json: history.any? ? Helpers::Json.dump(history) : nil,
79
+ outcome: body[:outcome]&.to_s,
80
+ total_attempts: body[:attempts] ? body[:attempts].to_i : history.size,
81
+ recorded_at: body[:recorded_at] || body[:timestamp] || Time.now.utc,
82
+ inserted_at: Time.now.utc
83
+ }.compact
84
+ end
85
+
86
+ def resolve_conversation_id(db, body, headers)
87
+ conv_ref = body[:conversation_id] || headers['x-legion-llm-conversation-id']
88
+ return nil unless conv_ref # rubocop:disable Legion/Extension/RunnerReturnHash
89
+
90
+ conv = db[:llm_conversations].where(uuid: stable_uuid(conv_ref)).first ||
91
+ db[:llm_conversations].where(uuid: conv_ref).first
92
+ conv&.[](:id)
93
+ end
94
+
95
+ def extract_error_category(attempt)
96
+ failures = Array(attempt[:failures])
97
+ failures.first.is_a?(Hash) ? failures.first[:category].to_s : nil
98
+ end
99
+
100
+ def stable_uuid(value)
101
+ raw = value.to_s
102
+ return raw if raw.length <= 36 # rubocop:disable Legion/Extension/RunnerReturnHash
103
+
104
+ hex = Digest::SHA256.hexdigest(raw)[0, 32]
105
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
106
+ end
107
+
108
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
109
+ Legion::Extensions::Helpers.const_defined?(:Lex, false)
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -92,18 +92,20 @@ module Legion
92
92
  caller_raw: payload[:caller], identity: payload[:identity], headers: headers
93
93
  )
94
94
  payload.merge(
95
- message_id: props[:message_id] || payload[:message_id] || ctx[:message_id],
96
- correlation_id: props[:correlation_id] || payload[:correlation_id],
97
- conversation_id: ctx[:conversation_id] || payload[:conversation_id],
98
- request_id: ctx[:request_id] || payload[:request_id],
99
- exchange_id: ctx[:exchange_id] || payload[:exchange_id],
100
- operation: payload[:operation] || payload[:request_type] || headers['x-legion-llm-request-type'],
101
- provider: payload[:provider] || headers['x-legion-llm-provider'],
102
- provider_instance: payload[:provider_instance] || payload[:instance],
103
- model_id: payload[:model_id] || headers['x-legion-llm-model'],
104
- tier: payload[:tier] || headers['x-legion-llm-tier'],
105
- caller_identity: identity[:identity],
106
- caller_type: identity[:type]
95
+ message_id: props[:message_id] || payload[:message_id] || ctx[:message_id],
96
+ correlation_id: props[:correlation_id] || payload[:correlation_id],
97
+ conversation_id: ctx[:conversation_id] || payload[:conversation_id],
98
+ request_id: ctx[:request_id] || payload[:request_id],
99
+ exchange_id: ctx[:exchange_id] || payload[:exchange_id],
100
+ operation: payload[:operation] || payload[:request_type] || headers['x-legion-llm-request-type'],
101
+ provider: payload[:provider] || headers['x-legion-llm-provider'],
102
+ provider_instance: payload[:provider_instance] || payload[:instance],
103
+ model_id: payload[:model_id] || headers['x-legion-llm-model'],
104
+ tier: payload[:tier] || headers['x-legion-llm-tier'],
105
+ caller_identity: identity[:identity],
106
+ caller_type: identity[:type],
107
+ __header_principal_id: identity[:principal_id],
108
+ __header_identity_id: identity[:identity_id]
107
109
  )
108
110
  end
109
111
 
@@ -10,6 +10,8 @@ module Legion
10
10
  module Ledger
11
11
  module Runners
12
12
  module Prompts
13
+ ALLOWED_CLASSIFICATION_LEVELS = %w[public internal confidential restricted].freeze
14
+
13
15
  extend self
14
16
 
15
17
  def write_prompt_record(payload = nil, metadata = {}, **message)
@@ -137,8 +139,10 @@ module Legion
137
139
  caller_raw: body[:caller], identity: body[:identity], headers: headers
138
140
  )
139
141
  {
140
- caller_identity: identity[:identity],
141
- caller_type: identity[:type]
142
+ caller_identity: identity[:identity],
143
+ caller_type: identity[:type],
144
+ __header_principal_id: identity[:principal_id],
145
+ __header_identity_id: identity[:identity_id]
142
146
  }.compact
143
147
  end
144
148
 
@@ -165,14 +169,25 @@ module Legion
165
169
  end
166
170
 
167
171
  def official_compliance_payload(body, headers, expires_at)
172
+ cls = body[:classification] || {}
168
173
  {
169
174
  retention_policy: headers['x-legion-retention'] || body[:retention_policy],
170
175
  expires_at: expires_at,
171
- contains_phi: headers['x-legion-contains-phi'] == 'true' || body.dig(:classification, :contains_phi),
172
- classification_level: body.dig(:classification, :level) || headers['x-legion-classification']
176
+ contains_phi: headers['x-legion-contains-phi'] == 'true' || cls[:contains_phi],
177
+ contains_pii: cls[:contains_pii] ? true : false,
178
+ pii_types_json: Helpers::Json.dump(Array(cls[:pii_types])),
179
+ classification_level: normalize_classification(cls[:level] || headers['x-legion-classification']),
180
+ jurisdictions: Helpers::Json.dump(Array(cls[:jurisdictions]))
173
181
  }
174
182
  end
175
183
 
184
+ def normalize_classification(level)
185
+ return 'internal' if level.nil? || level.to_s.empty? # rubocop:disable Legion/Extension/RunnerReturnHash
186
+
187
+ normalized = level.to_s.downcase
188
+ ALLOWED_CLASSIFICATION_LEVELS.include?(normalized) ? normalized : 'internal'
189
+ end
190
+
176
191
  include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
177
192
  Legion::Extensions::Helpers.const_defined?(:Lex, false)
178
193
  end
@@ -10,7 +10,7 @@ module Legion
10
10
 
11
11
  def health_report
12
12
  ds = official_metrics
13
- .where { Sequel[:llm_message_inference_metrics][:inserted_at] >= Time.now.utc - 86_400 }
13
+ .where { Sequel[:llm_message_inference_metrics][:recorded_at] >= Time.now.utc - 86_400 }
14
14
  .select_group(
15
15
  Sequel[:llm_message_inference_metrics][:provider],
16
16
  Sequel[:llm_message_inference_responses][:provider_instance],
@@ -0,0 +1,96 @@
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 Reconciliation
11
+ extend self
12
+ extend Legion::Logging::Helper
13
+
14
+ BATCH_SIZE = 200
15
+ LOOKBACK_SECONDS = 300
16
+
17
+ def link_orphaned_tool_calls
18
+ db = ::Legion::Data.connection
19
+ linked = 0
20
+
21
+ orphans = db[:llm_tool_calls]
22
+ .where(message_inference_response_id: nil)
23
+ .where { inserted_at >= Time.now.utc - LOOKBACK_SECONDS }
24
+ .limit(BATCH_SIZE)
25
+ .all
26
+
27
+ orphans.each do |tool_call|
28
+ response = find_response_for_tool_call(db, tool_call)
29
+ next unless response
30
+
31
+ db[:llm_tool_calls].where(id: tool_call[:id]).update(
32
+ message_inference_response_id: response[:id]
33
+ )
34
+ linked += 1
35
+ end
36
+
37
+ log.info("[ledger] reconciliation: linked #{linked} orphaned tool calls") if linked.positive?
38
+ { result: :ok, linked: linked }
39
+ rescue StandardError => e
40
+ handle_exception(e, level: :error, handled: true, operation: 'reconciliation.tool_calls')
41
+ { result: :error, error: e.message }
42
+ end
43
+
44
+ def link_metering_messages
45
+ db = ::Legion::Data.connection
46
+ linked = 0
47
+
48
+ requests_without_messages = db[:llm_message_inference_requests]
49
+ .where(latest_message_id: nil)
50
+ .where { inserted_at >= Time.now.utc - LOOKBACK_SECONDS }
51
+ .limit(BATCH_SIZE)
52
+ .all
53
+
54
+ requests_without_messages.each do |request|
55
+ next unless request[:conversation_id]
56
+
57
+ message = db[:llm_messages]
58
+ .where(conversation_id: request[:conversation_id])
59
+ .order(Sequel.desc(:seq))
60
+ .first
61
+ next unless message
62
+
63
+ db[:llm_message_inference_requests]
64
+ .where(id: request[:id])
65
+ .update(latest_message_id: message[:id])
66
+ linked += 1
67
+ end
68
+
69
+ log.info("[ledger] reconciliation: linked #{linked} metering requests to messages") if linked.positive?
70
+ { result: :ok, linked: linked }
71
+ rescue StandardError => e
72
+ handle_exception(e, level: :error, handled: true, operation: 'reconciliation.metering_messages')
73
+ { result: :error, error: e.message }
74
+ end
75
+
76
+ private
77
+
78
+ def find_response_for_tool_call(db, tool_call)
79
+ return nil unless tool_call[:conversation_id] # rubocop:disable Legion/Extension/RunnerReturnHash
80
+
81
+ request = db[:llm_message_inference_requests]
82
+ .where(conversation_id: tool_call[:conversation_id])
83
+ .order(Sequel.desc(:inserted_at))
84
+ .first
85
+ return nil unless request # rubocop:disable Legion/Extension/RunnerReturnHash
86
+
87
+ db[:llm_message_inference_responses]
88
+ .where(message_inference_request_id: request[:id])
89
+ .first
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -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]
@@ -106,7 +101,7 @@ module Legion
106
101
  conv&.[](:id)
107
102
  end
108
103
 
109
- def find_or_create_tool_call(db, response, body, ctx, tool, headers, identity_attrs, conversation_id)
104
+ def find_or_create_tool_call(db, response, body, ctx, tool, headers, identity_attrs, conversation_id) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
110
105
  tool_uuid = derive_tool_call_uuid(body, ctx, tool, headers)
111
106
  existing = db[:llm_tool_calls].where(uuid: tool_uuid).first
112
107
  return [existing, false] if existing # rubocop:disable Legion/Extension/RunnerReturnHash
@@ -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) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
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.6.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),
@@ -110,7 +112,7 @@ module Legion
110
112
  end
111
113
  end
112
114
 
113
- def find_or_create_request(db, conversation, latest_message, body)
115
+ def find_or_create_request(db, conversation, latest_message, body) # rubocop:disable Metrics/AbcSize
114
116
  request_id = request_ref(body)
115
117
  existing = db[:llm_message_inference_requests].where(request_ref: request_id).first
116
118
  return enrich_request!(db, existing, body, latest_message) if existing
@@ -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,15 @@ 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: phi_protect(json_dump(request_payload(body)), contains_phi?(body)),
141
144
  classification_level: classification_level(body),
142
145
  cost_center: billing(body)[:cost_center],
143
146
  budget_key: billing(body)[:budget_id] || billing(body)[:budget_key],
147
+ injected_tool_count: Array(body.dig(:audit, :injected_tools) || body[:injected_tools]).size,
148
+ context_tokens: resolve_context_tokens(body),
149
+ request_content_hash: compute_content_hash(body.dig(:request, :content) || body.dig(:audit, :request_content)),
150
+ curation_strategy: body[:curation_strategy] || body.dig(:audit, :curation_strategy),
151
+ tool_policy: body[:tool_policy] || body.dig(:audit, :tool_policy),
144
152
  requested_at: recorded_at(body),
145
153
  inserted_at: Time.now.utc
146
154
  }, operation: 'official_record_writer.inference_request')
@@ -186,14 +194,15 @@ module Legion
186
194
  end
187
195
  end
188
196
 
189
- def find_or_create_response(db, request, response_message, body)
190
- response_uuid = stable_uuid(reference(body, :provider_response_ref) || "response:#{request_ref(body)}")
197
+ def find_or_create_response(db, request, response_message, body) # rubocop:disable Metrics/AbcSize
198
+ response_uuid = stable_uuid(reference(body, :provider_response_ref) || "response:#{request_ref(body)}:#{body[:provider] || 'unknown'}")
191
199
  existing = db[:llm_message_inference_responses].where(uuid: response_uuid).first
192
200
  if existing
193
201
  enrich_response!(db, existing, response_message, body)
194
202
  return existing
195
203
  end
196
204
 
205
+ phi = contains_phi?(body)
197
206
  id = insert_with_savepoint(db, :llm_message_inference_responses, {
198
207
  uuid: response_uuid,
199
208
  message_inference_request_id: request[:id],
@@ -209,9 +218,15 @@ module Legion
209
218
  latency_ms: integer(body[:latency_ms]),
210
219
  wall_clock_ms: integer(body[:wall_clock_ms]),
211
220
  response_capture_mode: 'full',
212
- response_json: json_dump(visible_response(body)),
213
- response_thinking_json: json_dump(thinking_response(body)),
221
+ response_json: phi_protect(json_dump(visible_response(body)), phi),
222
+ response_thinking_json: phi_protect(json_dump(thinking_response(body)), phi),
214
223
  dispatch_path: body[:dispatch_path] || body[:tier],
224
+ error_category: body[:error_category] || body.dig(:error, :category),
225
+ error_code: body[:error_code] || body.dig(:error, :code),
226
+ error_message: body[:error_message] || body.dig(:error, :message),
227
+ response_content_hash: compute_content_hash(body[:response_content] || body.dig(:audit, :response_content)),
228
+ route_attempts: (body[:route_attempts] || body.dig(:audit, :route_attempts)).to_i,
229
+ escalation_chain_ref: body[:escalation_chain_ref],
215
230
  identity_principal_id: caller_identity_refs(db, body)[:principal_id],
216
231
  identity_id: caller_identity_refs(db, body)[:identity_id],
217
232
  identity_canonical_name: identity_canonical_name(body),
@@ -377,6 +392,10 @@ module Legion
377
392
  explicit_identity_id = integer_or_nil(body[:caller_identity_id] || body.dig(:caller, :requested_by, :id))
378
393
  explicit_principal_id = integer_or_nil(body[:caller_principal_id] ||
379
394
  body.dig(:caller, :requested_by, :principal_id))
395
+
396
+ explicit_identity_id ||= integer_or_nil(body[:__header_identity_id])
397
+ explicit_principal_id ||= integer_or_nil(body[:__header_principal_id])
398
+
380
399
  refs = { principal_id: explicit_principal_id, identity_id: explicit_identity_id }.compact
381
400
  unless refs[:principal_id] && refs[:identity_id]
382
401
  if explicit_identity_id && !explicit_principal_id && identity_tables_available?(db)
@@ -554,6 +573,18 @@ module Legion
554
573
  int.positive? ? int : nil
555
574
  end
556
575
 
576
+ def resolve_parent_request_id(db, body)
577
+ parent_ref = body[:parent_request_id] || body.dig(:context, :parent_request_id)
578
+ return nil unless present?(parent_ref)
579
+
580
+ if parent_ref.is_a?(Integer)
581
+ parent_ref
582
+ else
583
+ parent = db[:llm_message_inference_requests].where(request_ref: parent_ref.to_s).first
584
+ parent&.dig(:id)
585
+ end
586
+ end
587
+
557
588
  def correlation_id(body)
558
589
  reference(body, :correlation_id, :correlation_ref) || body.dig(:tracing, :correlation_id)
559
590
  end
@@ -656,8 +687,14 @@ module Legion
656
687
  body.dig(:response, :finish_reason) || body.dig(:response, :stop, :reason)
657
688
  end
658
689
 
690
+ ALLOWED_CLASSIFICATION_LEVELS = %w[public internal confidential restricted].freeze
691
+
659
692
  def classification_level(body)
660
- body[:classification_level] || body.dig(:classification, :level)
693
+ raw = body[:classification_level] || body.dig(:classification, :level)
694
+ return 'internal' if raw.nil? || raw.to_s.empty?
695
+
696
+ normalized = raw.to_s.downcase
697
+ ALLOWED_CLASSIFICATION_LEVELS.include?(normalized) ? normalized : 'internal'
661
698
  end
662
699
 
663
700
  def contains_phi?(body)
@@ -706,6 +743,19 @@ module Legion
706
743
  json_dump(content)
707
744
  end
708
745
 
746
+ def phi_protect(json_string, is_phi)
747
+ return json_string unless is_phi && crypt_available?
748
+
749
+ Legion::Crypt.encrypt(json_string)
750
+ rescue StandardError => e
751
+ handle_exception(e, level: :warn, handled: true, operation: 'official_record_writer.phi_encrypt')
752
+ json_string
753
+ end
754
+
755
+ def crypt_available?
756
+ defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:encrypt)
757
+ end
758
+
709
759
  def json_dump(value)
710
760
  Helpers::Json.dump(value)
711
761
  end
@@ -728,6 +778,20 @@ module Legion
728
778
  def present?(value)
729
779
  !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
730
780
  end
781
+
782
+ def compute_content_hash(content)
783
+ return nil if content.nil? || content.to_s.empty?
784
+
785
+ Digest::SHA256.hexdigest(json_dump(content))[0..31]
786
+ end
787
+
788
+ def resolve_context_tokens(body)
789
+ raw = body[:tokens] || body[:audit] || body
790
+ val = raw[:input_tokens] || raw[:input] || raw[:context_tokens] || raw[:prompt_tokens]
791
+ return nil unless present?(val)
792
+
793
+ val.to_i
794
+ end
731
795
  end
732
796
  end
733
797
  end
@@ -18,6 +18,10 @@ require_relative 'ledger/backfill/legacy_llm_records'
18
18
  require_relative 'ledger/runners/metering'
19
19
  require_relative 'ledger/runners/prompts'
20
20
  require_relative 'ledger/runners/tools'
21
+ require_relative 'ledger/runners/skills'
22
+ require_relative 'ledger/runners/escalations'
23
+ require_relative 'ledger/runners/retention_purge'
24
+ require_relative 'ledger/runners/reconciliation'
21
25
  require_relative 'ledger/runners/usage_reporter'
22
26
  require_relative 'ledger/runners/provider_stats'
23
27
  require_relative 'ledger/runners/registry_availability'
@@ -29,12 +33,18 @@ if defined?(Legion::Extensions) && Legion::Extensions.const_defined?(:Core, fals
29
33
  require_relative 'ledger/transport/queues/metering_write'
30
34
  require_relative 'ledger/transport/queues/audit_prompts'
31
35
  require_relative 'ledger/transport/queues/audit_tools'
36
+ require_relative 'ledger/transport/queues/audit_skills'
37
+ require_relative 'ledger/transport/queues/audit_escalations'
32
38
  require_relative 'ledger/transport/queues/registry_availability'
33
39
  require_relative 'ledger/transport/transport'
34
40
  require_relative 'ledger/actors/metering'
35
41
  require_relative 'ledger/actors/prompts'
36
42
  require_relative 'ledger/actors/tools'
43
+ require_relative 'ledger/actors/skills'
44
+ require_relative 'ledger/actors/escalations'
37
45
  require_relative 'ledger/actors/registry_availability'
46
+ require_relative 'ledger/actors/retention_purge'
47
+ require_relative 'ledger/actors/reconciliation'
38
48
  require_relative 'ledger/actors/spool_flush'
39
49
  end
40
50
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm-ledger
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -191,9 +191,13 @@ files:
191
191
  - Rakefile
192
192
  - lex-llm-ledger.gemspec
193
193
  - lib/legion/extensions/llm/ledger.rb
194
+ - lib/legion/extensions/llm/ledger/actors/escalations.rb
194
195
  - lib/legion/extensions/llm/ledger/actors/metering.rb
195
196
  - lib/legion/extensions/llm/ledger/actors/prompts.rb
197
+ - lib/legion/extensions/llm/ledger/actors/reconciliation.rb
196
198
  - lib/legion/extensions/llm/ledger/actors/registry_availability.rb
199
+ - lib/legion/extensions/llm/ledger/actors/retention_purge.rb
200
+ - lib/legion/extensions/llm/ledger/actors/skills.rb
197
201
  - lib/legion/extensions/llm/ledger/actors/spool_flush.rb
198
202
  - lib/legion/extensions/llm/ledger/actors/tools.rb
199
203
  - lib/legion/extensions/llm/ledger/backfill/legacy_llm_records.rb
@@ -217,16 +221,23 @@ files:
217
221
  - lib/legion/extensions/llm/ledger/helpers/retention.rb
218
222
  - lib/legion/extensions/llm/ledger/helpers/subscription_actor.rb
219
223
  - lib/legion/extensions/llm/ledger/helpers/subscription_message.rb
224
+ - lib/legion/extensions/llm/ledger/runners/escalations.rb
220
225
  - lib/legion/extensions/llm/ledger/runners/metering.rb
221
226
  - lib/legion/extensions/llm/ledger/runners/prompts.rb
222
227
  - lib/legion/extensions/llm/ledger/runners/provider_stats.rb
228
+ - lib/legion/extensions/llm/ledger/runners/reconciliation.rb
223
229
  - lib/legion/extensions/llm/ledger/runners/registry_availability.rb
230
+ - lib/legion/extensions/llm/ledger/runners/retention_purge.rb
231
+ - lib/legion/extensions/llm/ledger/runners/skills.rb
224
232
  - lib/legion/extensions/llm/ledger/runners/tools.rb
225
233
  - lib/legion/extensions/llm/ledger/runners/usage_reporter.rb
226
234
  - lib/legion/extensions/llm/ledger/transport/exchanges/audit.rb
235
+ - lib/legion/extensions/llm/ledger/transport/exchanges/escalation.rb
227
236
  - lib/legion/extensions/llm/ledger/transport/exchanges/metering.rb
228
237
  - lib/legion/extensions/llm/ledger/transport/exchanges/registry.rb
238
+ - lib/legion/extensions/llm/ledger/transport/queues/audit_escalations.rb
229
239
  - lib/legion/extensions/llm/ledger/transport/queues/audit_prompts.rb
240
+ - lib/legion/extensions/llm/ledger/transport/queues/audit_skills.rb
230
241
  - lib/legion/extensions/llm/ledger/transport/queues/audit_tools.rb
231
242
  - lib/legion/extensions/llm/ledger/transport/queues/metering_write.rb
232
243
  - lib/legion/extensions/llm/ledger/transport/queues/registry_availability.rb