lex-llm 0.7.3 → 0.8.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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +52 -67
  8. data/lib/legion/extensions/llm/canonical/params.rb +59 -33
  9. data/lib/legion/extensions/llm/canonical/request.rb +67 -54
  10. data/lib/legion/extensions/llm/canonical/response.rb +61 -76
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
  13. data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
  14. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  15. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  16. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  17. data/lib/legion/extensions/llm/canonical.rb +6 -5
  18. data/lib/legion/extensions/llm/configuration.rb +40 -10
  19. data/lib/legion/extensions/llm/connection.rb +8 -30
  20. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  21. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  22. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  23. data/lib/legion/extensions/llm/error.rb +0 -14
  24. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  25. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  26. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  27. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  28. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
  29. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  30. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
  31. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  32. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  33. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  34. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  35. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  36. data/lib/legion/extensions/llm/inventory/records.rb +82 -99
  37. data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
  38. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  39. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
  40. data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
  41. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
  42. data/lib/legion/extensions/llm/provider.rb +192 -315
  43. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  44. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  45. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  46. data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
  47. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  48. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  49. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  50. data/lib/legion/extensions/llm/streaming.rb +50 -35
  51. data/lib/legion/extensions/llm/taxonomies.rb +29 -17
  52. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  53. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  54. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  55. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  56. data/lib/legion/extensions/llm/utils.rb +23 -5
  57. data/lib/legion/extensions/llm/version.rb +1 -1
  58. data/lib/legion/extensions/llm.rb +10 -98
  59. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  60. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  61. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  62. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  63. data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
  64. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  65. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  66. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
  67. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  68. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  69. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  70. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  71. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  72. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  73. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  74. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  75. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
  76. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
  77. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  78. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  79. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  80. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  81. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
  82. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  83. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
  84. data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
  85. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  86. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  87. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  88. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  89. data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
  90. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
  91. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  92. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  93. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
  94. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
  95. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
  96. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  97. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  98. data/spec/legion/extensions/llm/provider_spec.rb +98 -78
  99. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  100. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  101. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  102. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
  103. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  104. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  105. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  106. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  107. data/spec/support/fake_llm_provider.rb +45 -39
  108. data/spec/support/fake_ssot_harness.rb +7 -2
  109. data/spec/support/ssot_registry_helpers.rb +3 -2
  110. metadata +15 -54
  111. data/lib/legion/extensions/llm/agent.rb +0 -366
  112. data/lib/legion/extensions/llm/aliases.json +0 -436
  113. data/lib/legion/extensions/llm/aliases.rb +0 -67
  114. data/lib/legion/extensions/llm/attachment.rb +0 -229
  115. data/lib/legion/extensions/llm/chat.rb +0 -354
  116. data/lib/legion/extensions/llm/chunk.rb +0 -10
  117. data/lib/legion/extensions/llm/content.rb +0 -81
  118. data/lib/legion/extensions/llm/context.rb +0 -33
  119. data/lib/legion/extensions/llm/embedding.rb +0 -33
  120. data/lib/legion/extensions/llm/image.rb +0 -109
  121. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  122. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
  123. data/lib/legion/extensions/llm/message.rb +0 -118
  124. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  125. data/lib/legion/extensions/llm/model/info.rb +0 -286
  126. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  127. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  128. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  129. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  130. data/lib/legion/extensions/llm/model.rb +0 -11
  131. data/lib/legion/extensions/llm/models.json +0 -57313
  132. data/lib/legion/extensions/llm/models.rb +0 -530
  133. data/lib/legion/extensions/llm/models_schema.json +0 -168
  134. data/lib/legion/extensions/llm/moderation.rb +0 -60
  135. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  136. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  137. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  138. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  139. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  140. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  141. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  142. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  143. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  144. data/lib/legion/extensions/llm/thinking.rb +0 -53
  145. data/lib/legion/extensions/llm/tokens.rb +0 -51
  146. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  147. data/lib/legion/extensions/llm/transcription.rb +0 -39
  148. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  149. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  150. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  151. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  152. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  153. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  154. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
  155. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  156. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  157. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  158. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  159. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  160. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  161. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  162. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  163. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
@@ -5,6 +5,7 @@ require 'concurrent'
5
5
  require_relative 'protocol'
6
6
  require_relative 'settings'
7
7
  require_relative 'token_validator'
8
+ require_relative 'fleet_envelope'
8
9
  require 'legion/extensions/llm/taxonomies'
9
10
  require 'legion/extensions/llm/inventory/errors'
10
11
  require 'legion/extensions/llm/inventory/identity'
@@ -14,7 +15,9 @@ module Legion
14
15
  module Extensions
15
16
  module Llm
16
17
  module Fleet
17
- # Applies responder-side policy and dispatches a fleet request to a local lex-llm provider.
18
+ # Applies responder-side policy and executes a fleet request against
19
+ # the captured registry callable. Protocol v3: exact execution only —
20
+ # one dispatch path (06 W1/W3), no provider-object topology.
18
21
  module WorkerExecution
19
22
  include Legion::Logging::Helper
20
23
  extend Legion::Logging::Helper
@@ -26,14 +29,17 @@ module Legion
26
29
 
27
30
  module_function
28
31
 
29
- def call(envelope:, registry: nil, provider: nil)
30
- validate_dispatch_target!(registry, provider)
32
+ # The single dispatch entry (06 W1).
33
+ def call(envelope:, registry:)
34
+ raise PolicyError, 'WorkerExecution.call requires registry' if registry.nil?
35
+
36
+ envelope = FleetEnvelope.wrap(envelope)
31
37
  claims = nil
32
38
  idempotency_key = nil
33
39
  claims = validate_identity!(envelope)
34
40
  validate_policy!(envelope)
35
41
  idempotency_key = validate_idempotency!(envelope)
36
- response = dispatch!(envelope: envelope, registry: registry, provider: provider)
42
+ response = dispatch!(envelope:, registry:)
37
43
  mark_idempotency_success!(idempotency_key) if idempotency_key
38
44
  TokenValidator.mark_replay!(claims[:jti]) if claims.is_a?(Hash)
39
45
  response
@@ -52,115 +58,51 @@ module Legion
52
58
  def validate_identity!(envelope)
53
59
  return true unless responder_setting(:require_auth, default: true)
54
60
 
55
- TokenValidator.validate!(token: envelope_value(envelope, :signed_token), envelope: envelope)
61
+ TokenValidator.validate!(token: envelope.signed_token, envelope: envelope.to_h)
56
62
  end
57
63
 
58
- def validate_policy!(_envelope) # rubocop:disable Naming/PredicateMethod
64
+ # W6 fail-closed: require_policy with no policy engine configured
65
+ # RAISES. The v2 warn-and-allow is deleted.
66
+ def validate_policy!(_envelope)
59
67
  return true unless responder_setting(:require_policy, default: false)
60
68
 
61
- log.warn('[fleet] require_policy is enabled but no policy engine is configured — allowing request')
62
- true
69
+ raise PolicyError, 'require_policy is enabled but no policy engine is configured'
63
70
  end
64
71
 
65
72
  def validate_idempotency!(envelope)
66
73
  return nil unless responder_setting(:require_idempotency, default: true)
67
74
 
68
- key = envelope_value(envelope, :idempotency_key)
75
+ key = envelope.idempotency_key
69
76
  raise PolicyError, 'fleet idempotency_key is required' if key.to_s.empty?
70
77
 
71
78
  reserve_idempotency_key!(key.to_s)
72
79
  key.to_s
73
80
  end
74
81
 
75
- def dispatch_local_provider!(envelope:, provider:)
76
- provider = provider.call(envelope) if provider.respond_to?(:call) && !provider.respond_to?(:chat)
77
- operation = envelope_value(envelope, :operation).to_sym
78
- params = normalize_hash(envelope_value(envelope, :params) || {})
79
- params = unpack_legacy_options(params)
80
- model = envelope_value(envelope, :model)
81
-
82
- case operation
83
- when :chat
84
- provider.chat(messages: params.fetch(:messages, []), model: model, **except(params, :messages))
85
- when :stream
86
- provider.stream_chat(messages: params.fetch(:messages, []), model: model, **except(params, :messages))
87
- when :embed
88
- provider.embed(text: params[:text], model: model, **except(params, :text))
89
- when :count_tokens
90
- provider.count_tokens(messages: params.fetch(:messages, []), model: model, **except(params, :messages))
91
- else
92
- raise PolicyError, "unsupported fleet operation: #{operation}"
93
- end
94
- end
95
-
96
82
  ERRORS = Legion::Extensions::Llm::Inventory::Errors
97
83
  IDENTITY = Legion::Extensions::Llm::Inventory::Identity
98
84
 
99
- def validate_dispatch_target!(registry, provider)
100
- return unless registry.nil? == provider.nil?
101
-
102
- raise PolicyError, 'WorkerExecution.call requires exactly one of registry or provider'
103
- end
104
-
105
- def dispatch!(envelope:, registry:, provider:)
106
- return dispatch_local_provider!(envelope: envelope, provider: provider) if provider
107
-
108
- if envelope_value(envelope, :execution_contract) == Protocol::EXACT_EXECUTION_CONTRACT
109
- exact_dispatch!(envelope: envelope, registry: registry)
110
- else
111
- legacy_registry_dispatch!(envelope: envelope, registry: registry)
112
- end
113
- end
114
-
115
- # Exact path: resolve by signed offering_id; no model resolution, provider
116
- # scan, :default, or first value.
117
- def exact_dispatch!(envelope:, registry:)
85
+ # The one dispatcher (06 W3): the exact resolution chain (W2). The
86
+ # claim's offering_id value is the lane's 5 tuple (field name kept for
87
+ # protocol continuity, D4); the claim resolves a lane exactly. The
88
+ # REQUESTED operation (envelope) is what gets dispatched — the lane
89
+ # constrains type/model/instance, the operation is a request property.
90
+ def dispatch!(envelope:, registry:)
118
91
  snapshot = registry.snapshot
119
- instance_key = exact_instance_key(envelope)
92
+ instance_key = IDENTITY::InstanceKey.new(
93
+ provider_family: envelope.provider, instance_id: envelope.provider_instance
94
+ )
120
95
  record = available_record!(snapshot, instance_key)
121
- offering = record.offerings_by_id[envelope_value(envelope, :offering_id)]
122
- raise ERRORS::ExactOfferingMismatchError, 'offering_id not on the activated instance' if offering.nil?
123
-
124
96
  operation = exact_operation(envelope)
125
- model = require_matching_model!(offering, envelope)
126
- require_supported!(offering, operation)
127
- execute_via_lane(registry, snapshot,
128
- { record: record, offering: offering, operation: operation, model: model, envelope: envelope })
129
- end
130
-
131
- # Registry-backed v2 compatibility path for a migrated provider: execute
132
- # only when (provider_family, instance, operation, model) resolves to
133
- # exactly one supported local offering.
134
- def legacy_registry_dispatch!(envelope:, registry:)
135
- snapshot = registry.snapshot
136
- instance_key = exact_instance_key(envelope)
137
- record = available_record!(snapshot, instance_key)
138
- operation = Legion::Extensions::Llm::Taxonomies.normalize_operation(value: envelope_value(envelope, :operation), allow_aliases: true)
139
- model = IDENTITY.normalize_text(value: envelope_value(envelope, :model), field: :model)
140
- matches = record.offerings_by_id.values.select { |o| o.model == model && o.operation_status(operation: operation) == :supported }
141
- raise ERRORS::ExactOfferingMismatchError, 'no matching local offering' if matches.empty?
142
- raise ERRORS::AmbiguousLegacyOfferingError, 'multiple matching local offerings' if matches.size > 1
143
-
144
- execute_via_lane(registry, snapshot,
145
- { record: record, offering: matches.first, operation: operation, model: model, envelope: envelope })
146
- end
147
-
148
- def execute_via_lane(registry, snapshot, resolution)
149
- lane = matching_lane!(snapshot, resolution[:record], resolution[:offering], resolution[:operation], resolution[:model])
97
+ lane = exact_lane!(snapshot, record, envelope, operation)
150
98
  lease = registry.acquire(callable_handle: lane.callable_handle)
151
99
  begin
152
- dispatch_operation(lease.callable, resolution[:operation], resolution[:model], exact_params(resolution[:envelope]))
100
+ dispatch_operation(lease.callable, operation, lane.model, exact_params(envelope))
153
101
  ensure
154
102
  lease.release
155
103
  end
156
104
  end
157
105
 
158
- def exact_instance_key(envelope)
159
- IDENTITY::InstanceKey.new(
160
- provider_family: envelope_value(envelope, :provider), instance_id: envelope_value(envelope, :provider_instance)
161
- )
162
- end
163
-
164
106
  def available_record!(snapshot, instance_key)
165
107
  record = snapshot.instance(instance_key: instance_key)
166
108
  raise ERRORS::ExactOfferingMismatchError, 'instance is absent, initializing, or unavailable' unless record && record.availability.state == :available
@@ -168,97 +110,134 @@ module Legion
168
110
  record
169
111
  end
170
112
 
171
- def exact_operation(envelope)
172
- Legion::Extensions::Llm::Taxonomies.normalize_operation(value: envelope_value(envelope, :operation), allow_aliases: false)
173
- end
113
+ # The exact lane the claim names: the 5-tuple claim value must parse
114
+ # and validate, resolve in the snapshot, and agree with the activated
115
+ # instance, the envelope's operation type, and the envelope's model.
116
+ # The operation is a request property matched against the lane type —
117
+ # the lane's operation member is the representative of its type, not
118
+ # the identity.
119
+ def exact_lane!(snapshot, record, envelope, operation)
120
+ IDENTITY.validate_lane_id!(value: envelope.offering_id)
121
+ lane = snapshot.lane(lane_id: envelope.offering_id)
122
+ raise ERRORS::ExactOfferingMismatchError, 'lane_id not on the activated instance' if lane.nil?
123
+ unless lane.instance_key == record.instance_key && lane.callable_handle.equal?(record.callable_handle)
124
+ raise ERRORS::ExactOfferingMismatchError, 'lane does not belong to the activated instance'
125
+ end
174
126
 
175
- def require_matching_model!(offering, envelope)
176
- model = IDENTITY.normalize_text(value: envelope_value(envelope, :model), field: :model)
177
- raise ERRORS::ExactOfferingMismatchError, 'model does not match the offering' unless offering.model == model
127
+ require_matching_operation!(lane, operation)
128
+ require_matching_model!(lane, envelope)
129
+ lane
130
+ end
178
131
 
179
- model
132
+ # 06 §5: an unknown operation is a contract error at the worker.
133
+ def exact_operation(envelope)
134
+ Legion::Extensions::Llm::Taxonomies.normalize_operation(value: envelope.operation)
135
+ rescue Legion::Extensions::Llm::Inventory::Errors::ValidationError
136
+ raise ContractError, "unsupported exact operation: #{envelope.operation}"
180
137
  end
181
138
 
182
- def require_supported!(offering, operation)
183
- return if offering.operation_status(operation: operation) == :supported
139
+ def require_matching_operation!(lane, operation)
140
+ lane_type = Legion::Extensions::Llm::Taxonomies.lane_type_for(operation: lane.operation)
141
+ return if Legion::Extensions::Llm::Taxonomies.lane_type_for(operation: operation) == lane_type
184
142
 
185
- raise ERRORS::ExactOfferingMismatchError, "operation #{operation} is not supported by the offering"
143
+ raise ERRORS::ExactOfferingMismatchError, "operation #{operation} does not match the claimed lane type"
186
144
  end
187
145
 
188
- def matching_lane!(snapshot, record, offering, operation, model)
189
- lane_id = IDENTITY.lane_id(instance_key: record.instance_key, operation: operation, model: model, offering_id: offering.offering_id)
190
- lane = snapshot.lane(lane_id: lane_id)
191
- valid = lane && lane.offering_id == offering.offering_id && lane.instance_key == record.instance_key &&
192
- lane.model == model && lane.operation == operation && lane.callable_handle.equal?(record.callable_handle)
193
- raise ERRORS::ExactOfferingMismatchError, 'no matching lane for the offering' unless valid
146
+ def require_matching_model!(lane, envelope)
147
+ model = IDENTITY.normalize_text(value: envelope.model, field: :model)
148
+ raise ERRORS::ExactOfferingMismatchError, 'model does not match the lane' unless lane.model == model
194
149
 
195
- lane
150
+ model
196
151
  end
197
152
 
153
+ # W5 params strictness: no :model key (the Selection-derived model is
154
+ # untouchable), no duplicate param spellings. Shape violations are
155
+ # contract errors (06 §5), not offering mismatches.
198
156
  def exact_params(envelope)
199
- raw = envelope_value(envelope, :params) || {}
157
+ raw = envelope.params || {}
200
158
  params = {}
201
159
  raw.each do |key, value|
202
160
  sym = key.respond_to?(:to_sym) ? key.to_sym : key
203
- raise ERRORS::ExactOfferingMismatchError, "duplicate param spelling for #{sym}" if params.key?(sym)
161
+ raise ContractError, "duplicate param spelling for #{sym}" if params.key?(sym)
204
162
 
205
163
  params[sym] = value
206
164
  end
207
- raise ERRORS::ExactOfferingMismatchError, 'params must not contain model' if params.key?(:model)
165
+ raise ContractError, 'params must not contain model' if params.key?(:model)
208
166
 
209
167
  params
210
168
  end
211
169
 
170
+ # W4 — the named rehydration boundary: the ONLY place serialized wire
171
+ # messages become Canonical::Message. Non-Hash/non-Message elements
172
+ # are contract errors.
173
+ def rehydrate_wire_messages(value)
174
+ Array(value).map do |message|
175
+ next message if message.is_a?(Canonical::Message)
176
+
177
+ unless message.is_a?(Hash)
178
+ raise ContractError,
179
+ "fleet wire message must be a serialized Canonical::Message, got #{message.class}"
180
+ end
181
+
182
+ Canonical::Message.from_hash(message)
183
+ end
184
+ end
185
+
212
186
  def dispatch_operation(callable, operation, model, params)
213
187
  case operation
214
- when :chat then callable.chat(messages: require_param!(params, :messages, operation), model: model, **except(params, :messages))
215
- when :stream_chat then callable.stream_chat(messages: require_param!(params, :messages, operation), model: model, **except(params, :messages))
216
- when :count_tokens then callable.count_tokens(messages: require_param!(params, :messages, operation), model: model, **except(params, :messages))
217
- when :embed then callable.embed(text: require_param!(params, :text, operation), model: model, **except(params, :text))
218
- when :image then dispatch_image(callable, model, params)
219
- when :transcribe then dispatch_audio(callable, :transcribe, model, params)
220
- when :translate then dispatch_audio(callable, :translate, model, params)
221
- when :speak then dispatch_speak(callable, model, params)
222
- when :moderate then callable.moderate(require_param!(params, :input, operation), model: model, **except(params, :input))
223
- else raise ERRORS::ExactOfferingMismatchError, "unsupported exact operation: #{operation}"
188
+ when :chat
189
+ callable.chat(rehydrate_wire_messages(require_param!(params, :messages, operation)), model: model,
190
+ **params.except(:messages))
191
+ when :stream_chat
192
+ callable.stream_chat(rehydrate_wire_messages(require_param!(params, :messages, operation)), model: model,
193
+ **params.except(:messages))
194
+ when :count_tokens
195
+ callable.count_tokens(messages: rehydrate_wire_messages(require_param!(params, :messages, operation)), model: model,
196
+ **params.except(:messages))
197
+ when :embed
198
+ callable.embed(text: require_param!(params, :text, operation), model: model, **params.except(:text))
199
+ when :image
200
+ dispatch_image(callable, model, params)
201
+ when :transcribe
202
+ dispatch_audio(callable, :transcribe, model, params)
203
+ when :translate
204
+ dispatch_audio(callable, :translate, model, params)
205
+ when :speak
206
+ dispatch_speak(callable, model, params)
207
+ when :moderate
208
+ callable.moderate(input: require_param!(params, :input, operation), model: model, **params.except(:input))
209
+ else
210
+ raise ContractError, "unsupported exact operation: #{operation}"
224
211
  end
225
212
  end
226
213
 
227
214
  def dispatch_image(callable, model, params)
228
215
  require_param!(params, :prompt, :image)
229
216
  require_param!(params, :size, :image)
230
- callable.image(prompt: params[:prompt], model: model, **except(params, :prompt))
217
+ callable.image(prompt: params[:prompt], model: model, **params.except(:prompt))
231
218
  end
232
219
 
233
220
  def dispatch_audio(callable, operation, model, params)
234
221
  require_param!(params, :audio_file, operation)
235
- raise ERRORS::ExactOfferingMismatchError, "#{operation} requires the language key" unless params.key?(:language)
222
+ raise ContractError, "#{operation} requires the language key" unless params.key?(:language)
236
223
 
237
224
  callable.public_send(
238
225
  operation, params[:audio_file], model: model, language: params[:language],
239
- **except(params, :audio_file, :model, :language)
226
+ **params.except(:audio_file, :model, :language)
240
227
  )
241
228
  end
242
229
 
243
230
  def dispatch_speak(callable, model, params)
244
231
  require_param!(params, :text, :speak)
245
- callable.speak(params[:text], model: model, voice: params[:voice], **except(params, :text, :model, :voice))
232
+ callable.speak(params[:text], model: model, voice: params[:voice], **params.except(:text, :model, :voice))
246
233
  end
247
234
 
248
235
  def require_param!(params, key, operation)
249
- raise ERRORS::ExactOfferingMismatchError, "#{operation} requires the #{key} param" unless params.key?(key)
236
+ raise ContractError, "#{operation} requires the #{key} param" unless params.key?(key)
250
237
 
251
238
  params[key]
252
239
  end
253
240
 
254
- def unpack_legacy_options(params)
255
- options = params.delete(:options)
256
- return params unless options.is_a?(Hash)
257
-
258
- normalize_hash(options).each { |key, value| params[key] = value unless params.key?(key) }
259
- params
260
- end
261
-
262
241
  def reset_idempotency_cache!
263
242
  @idempotency_keys = Concurrent::Map.new
264
243
  @idempotency_mutex = Mutex.new
@@ -325,28 +304,6 @@ module Legion
325
304
  def auth_required?
326
305
  Settings.value(:fleet, :auth, :require_signed_token, default: true) != false
327
306
  end
328
-
329
- def envelope_value(envelope, key)
330
- return nil unless envelope.respond_to?(:key?)
331
-
332
- envelope[key] || envelope[key.to_s]
333
- end
334
-
335
- def normalize_hash(hash)
336
- return {} unless hash.respond_to?(:each)
337
-
338
- hash.each_with_object({}) do |(key, value), result|
339
- result[key.respond_to?(:to_sym) ? key.to_sym : key] = value
340
- end
341
- end
342
-
343
- def except(hash, *keys)
344
- exclusions = keys.map(&:to_sym)
345
- hash.each_with_object({}) do |(key, value), result|
346
- normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
347
- result[normalized_key] = value unless exclusions.include?(normalized_key)
348
- end
349
- end
350
307
  end
351
308
  end
352
309
  end
@@ -26,7 +26,6 @@ module Legion
26
26
  class StaleCallableError < Error; end
27
27
  class CallableDisposedError < Error; end
28
28
  class ExactOfferingMismatchError < Error; end
29
- class AmbiguousLegacyOfferingError < Error; end
30
29
  end
31
30
  end
32
31
  end
@@ -51,7 +51,7 @@ module Legion
51
51
  # canonicalizes into capability `streaming`.
52
52
  OperationEvidence = ::Data.define(:operation, :status, :source, :observed_at, :metadata) do
53
53
  def initialize(operation:, status:, source:, observed_at: nil, metadata: {})
54
- canonical_operation = Taxonomies.normalize_operation(value: operation, allow_aliases: false)
54
+ canonical_operation = Taxonomies.normalize_operation(value: operation)
55
55
  Evidence.validate_evidence_status(status: status, allowed: Taxonomies::OPERATION_EVIDENCE_STATES)
56
56
  Evidence.validate_evidence_source(source: source)
57
57
  Evidence.enforce_unknown_only_source!(status: status, source: source)
@@ -1,19 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'digest'
4
3
  require 'legion/extensions/llm/inventory/errors'
4
+ require 'legion/extensions/llm/taxonomies'
5
5
 
6
6
  module Legion
7
7
  module Extensions
8
8
  module Llm
9
9
  module Inventory
10
- # Canonical field normalization plus the `off:v1:` and `lane:v1:`
11
- # length-framed SHA-256 encoders/validators. See
12
- # phase-1-lex-llm-additive.md section 8.
13
- #
14
- # Framing is binary: decimal UTF-8 byte length, ASCII ':', then the exact
15
- # UTF-8 bytes. It never uses a delimiter join, Ruby hash order, object ID,
16
- # or Ruby Hash#hash. Tier is never an identity input.
10
+ # Canonical field normalization plus the ONE 5-tuple lane-id composer.
11
+ # The lane id is `tier:provider_family:instance_id:type:model`, composed
12
+ # here and ONLY here (G22). Every surface that needs an id calls
13
+ # compose_lane_id; validation is shape/parse checks against Taxonomies
14
+ # (R1 boundary contract — it enforces the shape the code produces).
15
+ # The code produces ONLY 5-tuples; nothing else is guarded against.
17
16
  module Identity
18
17
  module_function
19
18
 
@@ -42,46 +41,63 @@ module Legion
42
41
  candidate
43
42
  end
44
43
 
45
- def length_frame(value:)
46
- bytes = value.to_s.b
47
- "#{bytes.bytesize}:".b + bytes
44
+ # The ONE 5-tuple composer (G22): byte-identical to the v0.6.16
45
+ # ScopedRefresher.compose_id. A pure join of the five identity fields —
46
+ # no normalization here; the fields are normalized by their owners
47
+ # (TIERS/TYPES symbols, InstanceKey, normalize_text) before composing.
48
+ def compose_lane_id(tier:, provider_family:, instance_id:, type:, model:)
49
+ "#{tier}:#{provider_family}:#{instance_id}:#{type}:#{model}"
48
50
  end
49
51
 
50
- def offering_id(instance_key:, provider_native_key:)
51
- digest = ::Digest::SHA256.hexdigest(
52
- length_frame(value: instance_key.provider_family) +
53
- length_frame(value: instance_key.instance_id) +
54
- length_frame(value: provider_native_key)
55
- )
56
- "off:v1:#{digest}"
57
- end
52
+ # Bounded parse: split(':', 5) — the 5th part keeps its colons, which
53
+ # is load-bearing: model names contain ':' (Ollama model:tag, Bedrock
54
+ # model ids). Structural only — field semantics are validate_lane_id!'s.
55
+ def parse_lane_id(id)
56
+ raise Errors::ValidationError, 'lane id must be a String or Symbol' unless text_input?(id)
57
+
58
+ parts = id.to_s.split(':', 5)
59
+ raise Errors::ValidationError, "lane id must have exactly 5 parts, got #{parts.length}" unless parts.length == 5
58
60
 
59
- def lane_id(instance_key:, operation:, model:, offering_id:)
60
- digest = ::Digest::SHA256.hexdigest(
61
- "lane-v1\x00".b +
62
- length_frame(value: instance_key.provider_family) +
63
- length_frame(value: instance_key.instance_id) +
64
- length_frame(value: operation) +
65
- length_frame(value: model) +
66
- length_frame(value: offering_id)
67
- )
68
- "lane:v1:#{digest}"
61
+ parts.freeze
69
62
  end
70
63
 
71
- def validate_offering_id!(value:, instance_key:, provider_native_key:)
72
- expected = offering_id(instance_key: instance_key, provider_native_key: provider_native_key)
73
- raise Errors::ValidationError, 'offering_id does not reproduce from its identity fields' unless value == expected
64
+ # Shape validation (R1 boundary contract): exactly 5 parts (bounded
65
+ # split — the model keeps its colons), part 1 in TIERS, part 4 in
66
+ # TYPES, parts 2/3/5 nonempty NFC. Any value that is not a 5 tuple
67
+ # fails the part-count check on its own — there is no special case
68
+ # for anything else.
69
+ def validate_lane_id!(value:)
70
+ parts = parse_lane_id(value)
71
+ tier, provider_family, instance_id, type, model = parts
72
+ raise Errors::ValidationError, "lane id tier must be a Taxonomies::TIERS value, got #{tier}" \
73
+ unless Taxonomies::TIERS.include?(tier.to_sym)
74
+ raise Errors::ValidationError, "lane id type must be a Taxonomies::TYPES value, got #{type}" \
75
+ unless Taxonomies::TYPES.include?(type.to_sym)
76
+ raise Errors::ValidationError, 'lane id provider_family must be a nonempty NFC String' \
77
+ unless nonempty_nfc?(provider_family)
78
+ raise Errors::ValidationError, 'lane id instance_id must be a nonempty NFC String' \
79
+ unless nonempty_nfc?(instance_id)
80
+ raise Errors::ValidationError, 'lane id model must be a nonempty NFC String' \
81
+ unless nonempty_nfc?(model)
74
82
 
75
83
  value
76
84
  end
77
85
 
78
- def validate_lane_id!(value:, instance_key:, operation:, model:, offering_id:)
79
- expected = lane_id(
80
- instance_key: instance_key, operation: operation, model: model, offering_id: offering_id
81
- )
82
- raise Errors::ValidationError, 'lane_id does not reproduce from its identity fields' unless value == expected
86
+ def nonempty_nfc?(string)
87
+ !string.empty? && string.unicode_normalized?(:nfc)
88
+ end
83
89
 
84
- value
90
+ # M6: the ONE config→instance-id derivation (R6 owner law).
91
+ # Instance identity is the operator's CONFIG NAME from the provider
92
+ # config; when the operator configured no instance name, "default"
93
+ # is the ordinary label (no reserved values — see InstanceKey).
94
+ # Consumers CARRY this derivation; they never mint a local variant
95
+ # (node canonical names, family fallbacks, and read-time synthesis
96
+ # are deleted).
97
+ def instance_id(config)
98
+ return config.instance_id.to_s if config.respond_to?(:instance_id) && config.instance_id
99
+
100
+ 'default'
85
101
  end
86
102
 
87
103
  def text_input?(value)
@@ -96,7 +112,7 @@ module Legion
96
112
  string.encoding == ::Encoding::UTF_8 ? string.dup : string.b.dup.force_encoding(::Encoding::UTF_8)
97
113
  end
98
114
 
99
- private_class_method :text_input?, :valid_utf8?, :utf8
115
+ private_class_method :text_input?, :valid_utf8?, :utf8, :nonempty_nfc?
100
116
 
101
117
  # A mandatory, immutable provider_family + instance_id pair. Provider
102
118
  # family alone is never executable; two instances of the same provider
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'digest'
4
3
  require 'securerandom'
5
4
  require 'legion/extensions/llm/inventory/errors'
6
5
  require 'legion/extensions/llm/inventory/identity'
@@ -12,24 +11,22 @@ module Legion
12
11
  # An opaque fenced publisher claim token. The raw secret is never
13
12
  # serialized, logged, included in a record/snapshot/exception, returned
14
13
  # separately, or compared for order. `publisher_id` and
15
- # `publisher_token_id` are safe correlation/fencing values derived from
16
- # the secret. See phase-1-lex-llm-additive.md section 11.1.
14
+ # `publisher_token_id` are safe UUID correlation values — the id law
15
+ # forbids digest-encoded ids on any wire/DB/log surface, so the token
16
+ # id is a UUID like every other lifecycle handle, NOT a derivation of
17
+ # the secret. Fencing is the constant-time secret comparison; the
18
+ # public ids correlate, they do not authenticate.
19
+ # See phase-1-lex-llm-additive.md section 11.1.
17
20
  class PublisherToken
18
- TOKEN_ID_DOMAIN = "publisher-token-id-v1\x00"
19
-
20
21
  attr_reader :instance_key, :publisher_id, :publisher_token_id
21
22
 
22
- def self.derive_token_id(secret)
23
- "ptok:v1:#{::Digest::SHA256.hexdigest(TOKEN_ID_DOMAIN.b + secret.b)}"
24
- end
25
-
26
- # Factory used by the registry: builds a fresh secret and derives the
27
- # public correlation IDs from it.
23
+ # Factory used by the registry: builds a fresh secret and mints the
24
+ # public correlation IDs.
28
25
  def self.issue(instance_key:)
29
26
  secret = ::SecureRandom.hex(32)
30
27
  new(
31
28
  instance_key: instance_key, secret: secret,
32
- publisher_id: "pub:v1:#{::SecureRandom.uuid}", publisher_token_id: derive_token_id(secret)
29
+ publisher_id: "pub:v1:#{::SecureRandom.uuid}", publisher_token_id: "ptok:v1:#{::SecureRandom.uuid}"
33
30
  )
34
31
  end
35
32