lex-llm 0.6.16 → 0.7.2

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/lex-llm.gemspec +1 -1
  4. data/lib/legion/extensions/llm/auto_registration.rb +3 -0
  5. data/lib/legion/extensions/llm/canonical/message.rb +5 -1
  6. data/lib/legion/extensions/llm/fleet/protocol.rb +12 -0
  7. data/lib/legion/extensions/llm/fleet/provider_responder.rb +40 -5
  8. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -0
  9. data/lib/legion/extensions/llm/fleet/worker_execution.rb +166 -2
  10. data/lib/legion/extensions/llm/inventory/callable_handle.rb +144 -0
  11. data/lib/legion/extensions/llm/inventory/errors.rb +34 -0
  12. data/lib/legion/extensions/llm/inventory/evidence.rb +134 -0
  13. data/lib/legion/extensions/llm/inventory/identity.rb +141 -0
  14. data/lib/legion/extensions/llm/inventory/immutable_value.rb +96 -0
  15. data/lib/legion/extensions/llm/inventory/probe_coordinator.rb +117 -0
  16. data/lib/legion/extensions/llm/inventory/probe_token.rb +146 -0
  17. data/lib/legion/extensions/llm/inventory/publisher.rb +116 -0
  18. data/lib/legion/extensions/llm/inventory/records.rb +599 -0
  19. data/lib/legion/extensions/llm/inventory/registry.rb +636 -0
  20. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +206 -0
  21. data/lib/legion/extensions/llm/inventory/snapshot.rb +91 -0
  22. data/lib/legion/extensions/llm/provider.rb +55 -29
  23. data/lib/legion/extensions/llm/routing/lane_key.rb +4 -0
  24. data/lib/legion/extensions/llm/routing/model_offering.rb +3 -0
  25. data/lib/legion/extensions/llm/routing/offering_registry.rb +2 -0
  26. data/lib/legion/extensions/llm/routing/provider_outcome.rb +54 -0
  27. data/lib/legion/extensions/llm/routing/records.rb +288 -0
  28. data/lib/legion/extensions/llm/settings_cascade.rb +120 -0
  29. data/lib/legion/extensions/llm/taxonomies.rb +90 -0
  30. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -1
  31. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +3 -1
  32. data/lib/legion/extensions/llm/version.rb +1 -1
  33. data/lib/legion/extensions/llm.rb +18 -0
  34. data/spec/legion/extensions/llm/canonical/message_spec.rb +24 -0
  35. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +1 -0
  36. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +1 -0
  37. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +1 -0
  38. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +1 -0
  39. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +1 -0
  40. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +1 -0
  41. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +84 -0
  42. data/spec/legion/extensions/llm/conformance/ssot_provider_conformance_spec.rb +13 -0
  43. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +196 -0
  44. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +179 -0
  45. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +26 -0
  46. data/spec/legion/extensions/llm/fleet_messages_spec.rb +14 -0
  47. data/spec/legion/extensions/llm/inventory/boot_spec.rb +80 -0
  48. data/spec/legion/extensions/llm/inventory/callable_handle_spec.rb +127 -0
  49. data/spec/legion/extensions/llm/inventory/evidence_spec.rb +138 -0
  50. data/spec/legion/extensions/llm/inventory/identity_spec.rb +280 -0
  51. data/spec/legion/extensions/llm/inventory/immutable_value_spec.rb +132 -0
  52. data/spec/legion/extensions/llm/inventory/probe_coordinator_spec.rb +107 -0
  53. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +107 -0
  54. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +144 -0
  55. data/spec/legion/extensions/llm/inventory/records_spec.rb +300 -0
  56. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +140 -0
  57. data/spec/legion/extensions/llm/inventory/registry_availability_spec.rb +152 -0
  58. data/spec/legion/extensions/llm/inventory/registry_callable_spec.rb +63 -0
  59. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +69 -0
  60. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +128 -0
  61. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +109 -0
  62. data/spec/legion/extensions/llm/provider_contract_spec.rb +20 -0
  63. data/spec/legion/extensions/llm/provider_spec.rb +83 -0
  64. data/spec/legion/extensions/llm/routing/provider_outcome_spec.rb +73 -0
  65. data/spec/legion/extensions/llm/routing/records_spec.rb +173 -0
  66. data/spec/legion/extensions/llm/settings_cascade_spec.rb +208 -0
  67. data/spec/legion/extensions/llm/taxonomies_spec.rb +51 -0
  68. data/spec/support/fake_llm_provider.rb +11 -0
  69. data/spec/support/fake_ssot_harness.rb +137 -0
  70. data/spec/support/ssot_registry_helpers.rb +75 -0
  71. metadata +40 -3
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/llm/inventory/errors'
4
+ require 'legion/extensions/llm/inventory/immutable_value'
5
+ require 'legion/extensions/llm/taxonomies'
6
+ require 'legion/extensions/llm/capabilities'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Llm
11
+ module Inventory
12
+ # Immutable operation, capability, and value evidence with
13
+ # authoritative/unknown semantics. See phase-1-lex-llm-additive.md
14
+ # section 9.
15
+ #
16
+ # For every evidence record `observed_at` is nil or a duplicated frozen
17
+ # Time used only for telemetry. It never participates in authority,
18
+ # ordering, freshness, recovery, or selection.
19
+ module Evidence
20
+ module_function
21
+
22
+ def normalize_observed_at(observed_at)
23
+ return nil if observed_at.nil?
24
+ raise Errors::ValidationError, 'observed_at must be a Time' unless observed_at.is_a?(::Time)
25
+
26
+ observed_at.dup.freeze
27
+ end
28
+
29
+ def validate_evidence_status(status:, allowed:)
30
+ raise Errors::ValidationError, "status must be one of #{allowed.inspect}" unless allowed.include?(status)
31
+
32
+ status
33
+ end
34
+
35
+ def validate_evidence_source(source:)
36
+ raise Errors::ValidationError, 'source is not one of Taxonomies::EVIDENCE_SOURCES' unless Taxonomies::EVIDENCE_SOURCES.include?(source)
37
+
38
+ source
39
+ end
40
+
41
+ def enforce_unknown_only_source!(status:, source:)
42
+ return unless Taxonomies::UNKNOWN_ONLY_EVIDENCE_SOURCES.include?(source)
43
+ return if status == :unknown
44
+
45
+ raise Errors::ValidationError, "source #{source} requires status :unknown"
46
+ end
47
+ end
48
+
49
+ # Evidence that a provider instance supports (or is not known to support)
50
+ # a canonical operation. `stream_chat` stays `stream_chat`; it never
51
+ # canonicalizes into capability `streaming`.
52
+ OperationEvidence = ::Data.define(:operation, :status, :source, :observed_at, :metadata) do
53
+ def initialize(operation:, status:, source:, observed_at: nil, metadata: {})
54
+ canonical_operation = Taxonomies.normalize_operation(value: operation, allow_aliases: false)
55
+ Evidence.validate_evidence_status(status: status, allowed: Taxonomies::OPERATION_EVIDENCE_STATES)
56
+ Evidence.validate_evidence_source(source: source)
57
+ Evidence.enforce_unknown_only_source!(status: status, source: source)
58
+
59
+ super(
60
+ operation: canonical_operation,
61
+ status: status,
62
+ source: source,
63
+ observed_at: Evidence.normalize_observed_at(observed_at),
64
+ metadata: ImmutableValue.copy_and_freeze(value: metadata, field: :metadata)
65
+ )
66
+ end
67
+
68
+ def supported? = status == :supported
69
+ def unsupported? = status == :unsupported
70
+ def unknown? = status == :unknown
71
+ end
72
+
73
+ # Evidence that a provider instance supports (or is not known to support)
74
+ # a canonical capability. Config permission, absent fields, default_false,
75
+ # failed probes, and guesses must be constructed as :unknown, never
76
+ # :unsupported.
77
+ CapabilityEvidence = ::Data.define(:capability, :status, :source, :observed_at, :metadata) do
78
+ def initialize(capability:, status:, source:, observed_at: nil, metadata: {})
79
+ canonical_capability = Legion::Extensions::Llm::Capabilities.canonical(capability)
80
+ raise Errors::ValidationError, "capability #{canonical_capability} is not canonical" unless Legion::Extensions::Llm::Capabilities::CANONICAL.include?(canonical_capability)
81
+
82
+ Evidence.validate_evidence_status(status: status, allowed: Taxonomies::CAPABILITY_EVIDENCE_STATES)
83
+ Evidence.validate_evidence_source(source: source)
84
+ Evidence.enforce_unknown_only_source!(status: status, source: source)
85
+
86
+ super(
87
+ capability: canonical_capability,
88
+ status: status,
89
+ source: source,
90
+ observed_at: Evidence.normalize_observed_at(observed_at),
91
+ metadata: ImmutableValue.copy_and_freeze(value: metadata, field: :metadata)
92
+ )
93
+ end
94
+
95
+ def supported? = status == :supported
96
+ def unsupported? = status == :unsupported
97
+ def unknown? = status == :unknown
98
+ end
99
+
100
+ # Evidence carrying a known scalar value (context limit, maximum output,
101
+ # embedding dimensions, tokenizer/estimator, immutable model revision) or
102
+ # an explicit :unknown. Each field uses a separate instance so one known
103
+ # field cannot make another field authoritative.
104
+ ValueEvidence = ::Data.define(:status, :value, :source, :observed_at, :metadata) do
105
+ def initialize(status:, source:, value: nil, observed_at: nil, metadata: {})
106
+ Evidence.validate_evidence_status(status: status, allowed: Taxonomies::VALUE_EVIDENCE_STATES)
107
+ Evidence.validate_evidence_source(source: source)
108
+ Evidence.enforce_unknown_only_source!(status: status, source: source)
109
+
110
+ case status
111
+ when :known
112
+ raise Errors::ValidationError, 'known ValueEvidence requires a non-nil value' if value.nil?
113
+ when :unknown
114
+ raise Errors::ValidationError, 'unknown ValueEvidence requires value: nil' unless value.nil?
115
+ end
116
+
117
+ copied_value = value.nil? ? nil : ImmutableValue.copy_and_freeze(value: value, field: :value)
118
+
119
+ super(
120
+ status: status,
121
+ value: copied_value,
122
+ source: source,
123
+ observed_at: Evidence.normalize_observed_at(observed_at),
124
+ metadata: ImmutableValue.copy_and_freeze(value: metadata, field: :metadata)
125
+ )
126
+ end
127
+
128
+ def known? = status == :known
129
+ def unknown? = status == :unknown
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'legion/extensions/llm/inventory/errors'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Llm
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.
17
+ module Identity
18
+ module_function
19
+
20
+ def normalize_text(value:, field:)
21
+ raise Errors::ValidationError, "#{field} must be a String or Symbol" unless text_input?(value)
22
+
23
+ string = value.to_s
24
+ raise Errors::ValidationError, "#{field} is not valid UTF-8" unless valid_utf8?(string)
25
+
26
+ trimmed = utf8(string).strip
27
+ raise Errors::ValidationError, "#{field} must not be empty" if trimmed.empty?
28
+ raise Errors::ValidationError, "#{field} must be NFC-normalized" unless trimmed.unicode_normalized?(:nfc)
29
+
30
+ trimmed.freeze
31
+ end
32
+
33
+ def normalize_enum(value:, field:, allowed:)
34
+ raise Errors::ValidationError, "#{field} must be a String or Symbol" unless text_input?(value)
35
+
36
+ trimmed = value.to_s.strip
37
+ raise Errors::ValidationError, "#{field} must not be empty" if trimmed.empty?
38
+
39
+ candidate = trimmed.to_sym
40
+ raise Errors::ValidationError, "#{field} is not one of the allowed values" unless allowed.include?(candidate)
41
+
42
+ candidate
43
+ end
44
+
45
+ def length_frame(value:)
46
+ bytes = value.to_s.b
47
+ "#{bytes.bytesize}:".b + bytes
48
+ end
49
+
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
58
+
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}"
69
+ end
70
+
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
74
+
75
+ value
76
+ end
77
+
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
83
+
84
+ value
85
+ end
86
+
87
+ def text_input?(value)
88
+ value.is_a?(::String) || value.is_a?(::Symbol)
89
+ end
90
+
91
+ def valid_utf8?(string)
92
+ [::Encoding::UTF_8, ::Encoding::US_ASCII].include?(string.encoding) && string.valid_encoding?
93
+ end
94
+
95
+ def utf8(string)
96
+ string.encoding == ::Encoding::UTF_8 ? string.dup : string.b.dup.force_encoding(::Encoding::UTF_8)
97
+ end
98
+
99
+ private_class_method :text_input?, :valid_utf8?, :utf8
100
+
101
+ # A mandatory, immutable provider_family + instance_id pair. Provider
102
+ # family alone is never executable; two instances of the same provider
103
+ # are independent targets. See section 8.1.
104
+ #
105
+ # instance_id is the operator's CONFIG NAME — the identity the router
106
+ # keys settings lookups (instances.<name>) by. physical_id is an
107
+ # optional SECONDARY field carrying the physical/derived id
108
+ # (e.g. "host:port" or "host:port/ak:<digest>") preserved for dedup
109
+ # and diagnostics. It is NOT identity: it never participates in
110
+ # equality, hashing, or registry-scope identity, so two config names
111
+ # pointing at the same endpoint stay distinct instances.
112
+ InstanceKey = ::Data.define(:provider_family, :instance_id, :physical_id) do
113
+ def initialize(provider_family:, instance_id:, physical_id: nil)
114
+ family = Identity.normalize_text(value: provider_family, field: :provider_family).downcase
115
+ raise Errors::ValidationError, 'provider_family must match /\A[a-z][a-z0-9_]*\z/' unless family.match?(/\A[a-z][a-z0-9_]*\z/)
116
+
117
+ instance = Identity.normalize_text(value: instance_id, field: :instance_id)
118
+ raise Errors::ValidationError, 'instance_id must not be the reserved value "default"' if instance == 'default'
119
+
120
+ physical = physical_id.nil? ? nil : Identity.normalize_text(value: physical_id, field: :physical_id)
121
+
122
+ super(provider_family: family.to_sym, instance_id: instance, physical_id: physical)
123
+ end
124
+
125
+ # Identity is exactly (provider_family, instance_id). The
126
+ # secondary physical_id never affects equality, hashing, or
127
+ # registry-scope identity.
128
+ def ==(other)
129
+ other.is_a?(self.class) && other.provider_family == provider_family && other.instance_id == instance_id
130
+ end
131
+ alias_method :eql?, :==
132
+
133
+ def hash
134
+ [self.class, provider_family, instance_id].hash
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/llm/inventory/errors'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Llm
8
+ module Inventory
9
+ # The single strict recursive copier/freezer for JSON-like metadata and
10
+ # collection fields. See phase-1-lex-llm-additive.md section 7.1.
11
+ #
12
+ # Records call this helper before `super`; they never implement a
13
+ # competing recursive-freeze helper. It recursively copies and freezes
14
+ # only nil, true/false, Symbol, Integer, finite Float, String, Time,
15
+ # Array, and Hash. Hash keys must be String or Symbol. Strings must be
16
+ # valid UTF-8. Time values are duplicated and frozen. Arrays and Hashes
17
+ # preserve order but never preserve a mutable nested reference. NaN,
18
+ # infinity, an unsupported object, a callable, or a cyclic Array/Hash
19
+ # raises ValidationError naming the field path.
20
+ module ImmutableValue
21
+ module_function
22
+
23
+ def copy_and_freeze(value:, field:)
24
+ deep_copy(value, field.to_s, [])
25
+ end
26
+
27
+ def deep_copy(value, path, ancestors)
28
+ case value
29
+ when nil, true, false, ::Symbol, ::Integer
30
+ value
31
+ when ::Float
32
+ raise Errors::ValidationError, "#{path} is not a finite Float" unless value.finite?
33
+
34
+ value
35
+ when ::String
36
+ raise Errors::ValidationError, "#{path} is not valid UTF-8" unless valid_utf8?(value)
37
+
38
+ value.dup.freeze
39
+ when ::Time
40
+ value.dup.freeze
41
+ when ::Array
42
+ copy_array(value, path, ancestors)
43
+ when ::Hash
44
+ copy_hash(value, path, ancestors)
45
+ else
46
+ raise Errors::ValidationError, "#{path} is an unsupported value type (#{value.class})"
47
+ end
48
+ end
49
+ # rubocop:enable Metrics/MethodLength
50
+
51
+ def copy_array(value, path, ancestors)
52
+ raise Errors::ValidationError, "#{path} contains a cyclic Array reference" if cyclic?(value, ancestors)
53
+
54
+ next_ancestors = ancestors + [value]
55
+ value.each_with_index.map do |element, index|
56
+ deep_copy(element, "#{path}[#{index}]", next_ancestors)
57
+ end.freeze
58
+ end
59
+
60
+ def copy_hash(value, path, ancestors)
61
+ raise Errors::ValidationError, "#{path} contains a cyclic Hash reference" if cyclic?(value, ancestors)
62
+
63
+ next_ancestors = ancestors + [value]
64
+ copied = {}
65
+ value.each do |key, element|
66
+ frozen_key = copy_key(key, path)
67
+ copied[frozen_key] = deep_copy(element, "#{path}.#{key}", next_ancestors)
68
+ end
69
+ copied.freeze
70
+ end
71
+
72
+ def copy_key(key, path)
73
+ case key
74
+ when ::Symbol
75
+ key
76
+ when ::String
77
+ raise Errors::ValidationError, "#{path} has a key that is not valid UTF-8" unless valid_utf8?(key)
78
+
79
+ key.dup.freeze
80
+ else
81
+ raise Errors::ValidationError, "#{path} has a non-String/Symbol key (#{key.class})"
82
+ end
83
+ end
84
+
85
+ def cyclic?(value, ancestors)
86
+ ancestors.any? { |ancestor| ancestor.equal?(value) }
87
+ end
88
+
89
+ def valid_utf8?(string)
90
+ [::Encoding::UTF_8, ::Encoding::US_ASCII].include?(string.encoding) && string.valid_encoding?
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/logging'
4
+ require 'legion/extensions/llm/inventory/errors'
5
+ require 'legion/extensions/llm/inventory/identity'
6
+ require 'legion/extensions/llm/inventory/probe_token'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Llm
11
+ module Inventory
12
+ # Enqueue-only probe handle with per-instance/revision coalescing and
13
+ # single-flight state. It owns no timer, thread, thread pool, provider
14
+ # API call, or registry transition. It is the `probe_request_handle`
15
+ # passed to Registry.claim_instance. See phase-1-lex-llm-additive.md
16
+ # section 13.2.
17
+ class ProbeCoordinator
18
+ include Legion::Logging::Helper
19
+
20
+ def initialize(instance_key:, enqueue:)
21
+ raise Errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(Identity::InstanceKey)
22
+ raise Errors::ValidationError, 'enqueue must be provided' if enqueue.nil?
23
+
24
+ @instance_key = instance_key
25
+ @enqueue = enqueue
26
+ @pending = nil
27
+ @in_flight_form = nil
28
+ @in_flight_request = nil
29
+ @mutex = Mutex.new
30
+ end
31
+
32
+ # Registry calls this after a root swap on dispatch_instance_unavailable.
33
+ def enqueue_probe_request(instance_key:, publisher_token_id:, unavailable_revision:, reason:)
34
+ request = ProbeRequest.new(
35
+ instance_key: instance_key, publisher_token_id: publisher_token_id,
36
+ unavailable_revision: unavailable_revision, reason: reason
37
+ )
38
+ raise Errors::ValidationError, 'instance_key mismatch' unless request.instance_key == @instance_key
39
+
40
+ to_enqueue = nil
41
+ @mutex.synchronize do
42
+ had_pending = !@pending.nil?
43
+ retain_greater(request)
44
+ to_enqueue = @pending if @in_flight_form.nil? && !had_pending
45
+ end
46
+ return true if to_enqueue.nil?
47
+
48
+ run_enqueue(to_enqueue)
49
+ end
50
+
51
+ def begin_probe(request: nil)
52
+ @mutex.synchronize do
53
+ return false unless @in_flight_form.nil?
54
+
55
+ if request.nil?
56
+ @in_flight_form = :cadence
57
+ @in_flight_request = nil
58
+ else
59
+ raise Errors::ValidationError, 'request must be a ProbeRequest' unless request.is_a?(ProbeRequest)
60
+ return false unless @pending && request.equal?(@pending)
61
+
62
+ @in_flight_form = :request
63
+ @in_flight_request = request
64
+ @pending = nil
65
+ end
66
+ true
67
+ end
68
+ end
69
+
70
+ def finish_probe(request: nil)
71
+ still_pending = nil
72
+ @mutex.synchronize do
73
+ validate_finish_form!(request)
74
+ @in_flight_form = nil
75
+ @in_flight_request = nil
76
+ still_pending = @pending
77
+ end
78
+ run_enqueue(still_pending) unless still_pending.nil?
79
+ still_pending
80
+ end
81
+
82
+ def pending?(unavailable_revision:)
83
+ @mutex.synchronize { !@pending.nil? && @pending.unavailable_revision == unavailable_revision }
84
+ end
85
+
86
+ def in_flight?
87
+ @mutex.synchronize { !@in_flight_form.nil? }
88
+ end
89
+
90
+ private
91
+
92
+ def retain_greater(request)
93
+ @pending = request if @pending.nil? || request.unavailable_revision > @pending.unavailable_revision
94
+ end
95
+
96
+ def validate_finish_form!(request)
97
+ if request.nil?
98
+ raise Errors::InvalidTransitionError, 'no cadence probe in flight' unless @in_flight_form == :cadence
99
+ else
100
+ raise Errors::InvalidTransitionError, 'finish request does not match the in-flight probe' unless @in_flight_form == :request && @in_flight_request.equal?(request)
101
+ end
102
+ end
103
+
104
+ def run_enqueue(request)
105
+ @enqueue.call(request: request) ? true : false
106
+ rescue StandardError => e
107
+ handle_exception(
108
+ e, handled: true, level: :warn, operation: 'llm.inventory.probe_coordinator.enqueue',
109
+ provider_family: @instance_key.provider_family, instance_id: @instance_key.instance_id
110
+ )
111
+ false
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'securerandom'
5
+ require 'legion/extensions/llm/inventory/errors'
6
+ require 'legion/extensions/llm/inventory/identity'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Llm
11
+ module Inventory
12
+ # An opaque fenced publisher claim token. The raw secret is never
13
+ # serialized, logged, included in a record/snapshot/exception, returned
14
+ # 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.
17
+ class PublisherToken
18
+ TOKEN_ID_DOMAIN = "publisher-token-id-v1\x00"
19
+
20
+ attr_reader :instance_key, :publisher_id, :publisher_token_id
21
+
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.
28
+ def self.issue(instance_key:)
29
+ secret = ::SecureRandom.hex(32)
30
+ new(
31
+ instance_key: instance_key, secret: secret,
32
+ publisher_id: "pub:v1:#{::SecureRandom.uuid}", publisher_token_id: derive_token_id(secret)
33
+ )
34
+ end
35
+
36
+ def initialize(instance_key:, secret:, publisher_id:, publisher_token_id:)
37
+ raise Errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(Identity::InstanceKey)
38
+ raise Errors::ValidationError, 'secret must be a nonempty String' unless nonempty_string?(secret)
39
+ raise Errors::ValidationError, 'publisher_id must be a pub:v1: String' unless publisher_id.to_s.start_with?('pub:v1:')
40
+ raise Errors::ValidationError, 'publisher_token_id must be a ptok:v1: String' unless publisher_token_id.to_s.start_with?('ptok:v1:')
41
+
42
+ @instance_key = instance_key
43
+ @secret = secret.b.dup.freeze
44
+ @publisher_id = publisher_id.dup.freeze
45
+ @publisher_token_id = publisher_token_id.dup.freeze
46
+ freeze
47
+ end
48
+
49
+ # Fenced validation: same InstanceKey, same public token ID, and a
50
+ # constant-time equal-length secret comparison using a local XOR
51
+ # accumulator (no ActiveSupport).
52
+ def authenticates?(other)
53
+ other.is_a?(PublisherToken) &&
54
+ instance_key == other.instance_key &&
55
+ publisher_token_id == other.publisher_token_id &&
56
+ secrets_match?(other)
57
+ end
58
+
59
+ def inspect
60
+ "#<#{self.class.name} instance_key=#{@instance_key.inspect} " \
61
+ "publisher_id=#{@publisher_id} publisher_token_id=#{@publisher_token_id} secret=[REDACTED]>"
62
+ end
63
+ alias to_s inspect
64
+
65
+ protected
66
+
67
+ def secret_bytes
68
+ @secret
69
+ end
70
+
71
+ private
72
+
73
+ def secrets_match?(other)
74
+ mine = @secret
75
+ theirs = other.secret_bytes
76
+ return false unless mine.bytesize == theirs.bytesize
77
+
78
+ accumulator = 0
79
+ mine.bytes.each_with_index { |byte, index| accumulator |= byte ^ theirs.getbyte(index) }
80
+ accumulator.zero?
81
+ end
82
+
83
+ def nonempty_string?(value)
84
+ value.is_a?(::String) && !value.empty?
85
+ end
86
+ end
87
+
88
+ # A single-use safe-readiness probe token. Carries no secret. See 11.2.
89
+ ProbeToken = ::Data.define(:token_id, :instance_key, :publisher_token_id, :started_availability_revision, :started_at) do
90
+ def self.issue(instance_key:, publisher_token_id:, started_availability_revision:, started_at:)
91
+ new(
92
+ token_id: "probe:v1:#{::SecureRandom.uuid}", instance_key: instance_key,
93
+ publisher_token_id: publisher_token_id, started_availability_revision: started_availability_revision,
94
+ started_at: started_at
95
+ )
96
+ end
97
+
98
+ def initialize(token_id:, instance_key:, publisher_token_id:, started_availability_revision:, started_at:)
99
+ raise Errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(Identity::InstanceKey)
100
+ raise Errors::ValidationError, 'token_id must be a probe:v1: String' unless token_id.to_s.start_with?('probe:v1:')
101
+ raise Errors::ValidationError, 'publisher_token_id must be a ptok:v1: String' unless publisher_token_id.is_a?(::String) && publisher_token_id.start_with?('ptok:v1:')
102
+ unless started_availability_revision.is_a?(::Integer) && !started_availability_revision.negative?
103
+ raise Errors::ValidationError, 'started_availability_revision must be a nonnegative Integer'
104
+ end
105
+ raise Errors::ValidationError, 'started_at must be a Time' unless started_at.is_a?(::Time)
106
+
107
+ super(
108
+ token_id: token_id.dup.freeze, instance_key: instance_key,
109
+ publisher_token_id: publisher_token_id.dup.freeze,
110
+ started_availability_revision: started_availability_revision, started_at: started_at.dup.freeze
111
+ )
112
+ end
113
+ end
114
+
115
+ # A process-local enqueue value asking the owning provider actor to run
116
+ # its safe readiness operation. Grants no registry mutation authority and
117
+ # carries no authorization secret. See section 11.2.
118
+ ProbeRequest = ::Data.define(:instance_key, :publisher_token_id, :unavailable_revision, :reason) do
119
+ def initialize(instance_key:, publisher_token_id:, unavailable_revision:, reason:)
120
+ raise Errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(Identity::InstanceKey)
121
+ raise Errors::ValidationError, 'publisher_token_id must be a nonempty ptok:v1: String' unless publisher_token_id.is_a?(::String) && publisher_token_id.start_with?('ptok:v1:')
122
+ raise Errors::ValidationError, 'unavailable_revision must be a positive Integer' unless unavailable_revision.is_a?(::Integer) && unavailable_revision.positive?
123
+
124
+ super(
125
+ instance_key: instance_key, publisher_token_id: publisher_token_id.dup.freeze,
126
+ unavailable_revision: unavailable_revision, reason: sanitized_reason(reason)
127
+ )
128
+ end
129
+
130
+ def sanitized_reason(reason)
131
+ unless reason.is_a?(::String) &&
132
+ [::Encoding::UTF_8, ::Encoding::US_ASCII].include?(reason.encoding) && reason.valid_encoding?
133
+ raise Errors::ValidationError, 'reason must be a valid UTF-8 String'
134
+ end
135
+
136
+ trimmed = reason.strip
137
+ raise Errors::ValidationError, 'reason must not be empty' if trimmed.empty?
138
+ raise Errors::ValidationError, 'reason exceeds 1024 UTF-8 bytes' if trimmed.bytesize > 1024
139
+
140
+ trimmed.b.dup.force_encoding(::Encoding::UTF_8).freeze
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end