fiber_audit 0.1.0 → 0.2.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.fiber-audit.example.yml +19 -0
  3. data/ARCHITECTURE.md +726 -0
  4. data/CHANGELOG.md +30 -0
  5. data/LICENSE +201 -0
  6. data/README.md +66 -8
  7. data/lib/fiber_audit/cli.rb +87 -2
  8. data/lib/fiber_audit/configuration.rb +106 -6
  9. data/lib/fiber_audit/errors.rb +2 -0
  10. data/lib/fiber_audit/operation_vocabulary.rb +42 -0
  11. data/lib/fiber_audit/reporters/text.rb +1 -1
  12. data/lib/fiber_audit/runtime/active_operations.rb +146 -0
  13. data/lib/fiber_audit/runtime/boot.rb +83 -0
  14. data/lib/fiber_audit/runtime/clock.rb +35 -0
  15. data/lib/fiber_audit/runtime/environment.rb +289 -0
  16. data/lib/fiber_audit/runtime/event.rb +86 -0
  17. data/lib/fiber_audit/runtime/execution_context.rb +89 -0
  18. data/lib/fiber_audit/runtime/heartbeat.rb +113 -0
  19. data/lib/fiber_audit/runtime/jsonl/schema.rb +312 -0
  20. data/lib/fiber_audit/runtime/jsonl/writer.rb +122 -0
  21. data/lib/fiber_audit/runtime/lifecycle.rb +343 -0
  22. data/lib/fiber_audit/runtime/limits.rb +102 -0
  23. data/lib/fiber_audit/runtime/location.rb +44 -0
  24. data/lib/fiber_audit/runtime/policy.rb +121 -0
  25. data/lib/fiber_audit/runtime/probes/base.rb +333 -0
  26. data/lib/fiber_audit/runtime/probes/http.rb +80 -0
  27. data/lib/fiber_audit/runtime/probes/io_select.rb +50 -0
  28. data/lib/fiber_audit/runtime/probes/registry.rb +156 -0
  29. data/lib/fiber_audit/runtime/probes/socket.rb +76 -0
  30. data/lib/fiber_audit/runtime/probes/subprocess.rb +83 -0
  31. data/lib/fiber_audit/runtime/probes/synchronization.rb +58 -0
  32. data/lib/fiber_audit/runtime/probes/thread_state.rb +39 -0
  33. data/lib/fiber_audit/runtime/probes/thread_wait.rb +25 -0
  34. data/lib/fiber_audit/runtime/rails_integration.rb +279 -0
  35. data/lib/fiber_audit/runtime/recorder.rb +333 -0
  36. data/lib/fiber_audit/runtime/redactor.rb +102 -0
  37. data/lib/fiber_audit/runtime/sampler.rb +26 -0
  38. data/lib/fiber_audit/runtime/scheduler_observer.rb +128 -0
  39. data/lib/fiber_audit/runtime/session.rb +112 -0
  40. data/lib/fiber_audit/runtime/supervisor.rb +114 -0
  41. data/lib/fiber_audit/runtime/validation.rb +68 -0
  42. data/lib/fiber_audit/runtime/watchdog.rb +479 -0
  43. data/lib/fiber_audit/runtime/watchdog_policy.rb +64 -0
  44. data/lib/fiber_audit/runtime.rb +37 -0
  45. data/lib/fiber_audit/static/rules/blocking_subprocess.rb +3 -8
  46. data/lib/fiber_audit/static/rules/direct_socket.rb +2 -3
  47. data/lib/fiber_audit/static/rules/io_select.rb +2 -4
  48. data/lib/fiber_audit/static/rules/net_http_in_request.rb +3 -5
  49. data/lib/fiber_audit/static/rules/synchronization.rb +2 -6
  50. data/lib/fiber_audit/static/rules/thread_current_state.rb +3 -2
  51. data/lib/fiber_audit/static/rules/thread_join.rb +3 -2
  52. data/lib/fiber_audit/version.rb +1 -1
  53. data/lib/fiber_audit.rb +1 -0
  54. metadata +38 -2
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../execution_context'
4
+ require_relative 'location'
5
+ require_relative 'validation'
6
+
7
+ module FiberAudit
8
+ module Runtime
9
+ Event = Data.define(
10
+ :kind,
11
+ :source,
12
+ :occurred_at,
13
+ :monotonic_ns,
14
+ :duration_ns,
15
+ :operation,
16
+ :location,
17
+ :execution_context,
18
+ :thread_id,
19
+ :fiber_id,
20
+ :measurements
21
+ ) do
22
+ def initialize(
23
+ kind:,
24
+ source:,
25
+ occurred_at:,
26
+ monotonic_ns:,
27
+ duration_ns: nil,
28
+ operation: nil,
29
+ location: nil,
30
+ execution_context: :unknown,
31
+ thread_id: nil,
32
+ fiber_id: nil,
33
+ measurements: {}
34
+ )
35
+ super(
36
+ kind: Validation.identifier(kind, 'kind'),
37
+ source: Validation.identifier(source, 'source'),
38
+ occurred_at: Validation.utc_time(occurred_at, 'occurred_at'),
39
+ monotonic_ns: Validation.integer(monotonic_ns, 'monotonic_ns'),
40
+ duration_ns: Validation.integer(duration_ns, 'duration_ns', allow_nil: true),
41
+ operation: Validation.operation(operation, allow_nil: true),
42
+ location: normalize_location(location),
43
+ execution_context: normalize_context(execution_context),
44
+ thread_id: Validation.integer(thread_id, 'thread_id', allow_nil: true),
45
+ fiber_id: Validation.integer(fiber_id, 'fiber_id', allow_nil: true),
46
+ measurements: normalize_measurements(measurements)
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ def normalize_location(value)
53
+ return value if value.nil? || value.is_a?(Location)
54
+
55
+ raise RuntimeContractError, 'location must be a FiberAudit::Runtime::Location or nil'
56
+ end
57
+
58
+ def normalize_context(value)
59
+ normalized = value.is_a?(String) || value.is_a?(Symbol) ? value.to_sym : nil
60
+ return normalized if Context::ALL.include?(normalized)
61
+
62
+ raise RuntimeContractError, "execution_context is invalid: #{value.inspect}"
63
+ end
64
+
65
+ def normalize_measurements(value)
66
+ raise RuntimeContractError, 'measurements must be a Hash' unless value.is_a?(Hash)
67
+ if value.size > Event::MAX_MEASUREMENTS
68
+ raise RuntimeContractError, "measurements must contain at most #{Event::MAX_MEASUREMENTS} entries"
69
+ end
70
+
71
+ value.each_with_object({}) do |(key, measurement), normalized|
72
+ name = Validation.identifier(key, 'measurement key').to_s.freeze
73
+ raise RuntimeContractError, "duplicate normalized measurement key: #{name}" if normalized.key?(name)
74
+ unless measurement.nil? || measurement == true || measurement == false ||
75
+ (measurement.is_a?(Numeric) && measurement.finite?)
76
+ raise RuntimeContractError, "measurement #{name} must be a finite number, Boolean, or nil"
77
+ end
78
+
79
+ normalized[name] = measurement
80
+ end.freeze
81
+ end
82
+ end
83
+
84
+ Event.const_set(:MAX_MEASUREMENTS, 32)
85
+ end
86
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../execution_context'
4
+
5
+ module FiberAudit
6
+ module Runtime
7
+ # Fiber-local execution context stack.
8
+ # Uses fiber instance variables for isolation without Thread#[] visibility.
9
+ # PID-aware to handle fork correctly.
10
+ module ExecutionContext
11
+ MAX_DEPTH = 32
12
+ IVAR_KEY = :@__fiber_audit_execution_context__
13
+ IVAR_PID_KEY = :@__fiber_audit_execution_context_pid__
14
+
15
+ class << self
16
+ def current
17
+ state = current_state
18
+ return Context::UNKNOWN unless state
19
+
20
+ state[:stack].last || Context::UNKNOWN
21
+ end
22
+
23
+ def with(context)
24
+ normalized = validate_context(context)
25
+ state = ensure_state
26
+ return yield if state[:stack].size >= MAX_DEPTH
27
+
28
+ state[:stack].push(normalized)
29
+ begin
30
+ yield
31
+ ensure
32
+ state[:stack].pop
33
+ end
34
+ end
35
+
36
+ def reset!
37
+ fiber = Fiber.current
38
+ fiber.remove_instance_variable(IVAR_KEY) if fiber.instance_variable_defined?(IVAR_KEY)
39
+ fiber.remove_instance_variable(IVAR_PID_KEY) if fiber.instance_variable_defined?(IVAR_PID_KEY)
40
+ end
41
+
42
+ def after_fork!
43
+ reset!
44
+ end
45
+
46
+ private
47
+
48
+ def current_state
49
+ fiber = Fiber.current
50
+ return nil unless fiber.instance_variable_defined?(IVAR_KEY)
51
+
52
+ pid = fiber.instance_variable_defined?(IVAR_PID_KEY) ? fiber.instance_variable_get(IVAR_PID_KEY) : nil
53
+ return nil unless pid == Process.pid
54
+
55
+ { stack: fiber.instance_variable_get(IVAR_KEY), pid: pid }
56
+ end
57
+
58
+ def ensure_state
59
+ fiber = Fiber.current
60
+ pid = Process.pid
61
+
62
+ if fiber.instance_variable_defined?(IVAR_KEY)
63
+ stored_pid = fiber.instance_variable_defined?(IVAR_PID_KEY) ? fiber.instance_variable_get(IVAR_PID_KEY) : nil
64
+ return { stack: fiber.instance_variable_get(IVAR_KEY), pid: pid } if stored_pid == pid
65
+
66
+ # PID mismatch - reset
67
+ reset!
68
+ end
69
+
70
+ stack = []
71
+ fiber.instance_variable_set(IVAR_KEY, stack)
72
+ fiber.instance_variable_set(IVAR_PID_KEY, pid)
73
+ { stack: stack, pid: pid }
74
+ end
75
+
76
+ def validate_context(value)
77
+ unless value.is_a?(Symbol) || value.is_a?(String)
78
+ raise RuntimeContractError, 'execution_context must be a Symbol or String'
79
+ end
80
+
81
+ normalized = value.is_a?(Symbol) ? value : value.to_sym
82
+ return normalized if Context::ALL.include?(normalized)
83
+
84
+ raise RuntimeContractError, 'execution_context is invalid'
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'clock'
4
+
5
+ module FiberAudit
6
+ module Runtime
7
+ # Scheduler-owned progress fiber observed by the watchdog thread.
8
+ class Heartbeat
9
+ Snapshot = Data.define(:sequence, :last_progress_ns, :thread_id, :fiber_id, :started, :stop_requested)
10
+
11
+ attr_reader :owner_thread
12
+
13
+ def initialize(
14
+ interval_ns:,
15
+ clock: Clock.new,
16
+ owner_thread: Thread.current,
17
+ on_tick: ->(_heartbeat) {},
18
+ on_error: ->(_heartbeat, _error) {}
19
+ )
20
+ raise RuntimeContractError, 'clock must be a FiberAudit::Runtime::Clock' unless clock.is_a?(Clock)
21
+ unless interval_ns.is_a?(Integer) && interval_ns.positive?
22
+ raise RuntimeContractError, 'interval_ns must be a positive Integer'
23
+ end
24
+ raise RuntimeContractError, 'owner_thread must be a Thread' unless owner_thread.is_a?(Thread)
25
+ raise RuntimeContractError, 'on_tick must respond to call' unless on_tick.respond_to?(:call)
26
+ raise RuntimeContractError, 'on_error must respond to call' unless on_error.respond_to?(:call)
27
+
28
+ @clock = clock
29
+ @interval_ns = interval_ns
30
+ @owner_thread = owner_thread
31
+ @on_tick = on_tick
32
+ @on_error = on_error
33
+ @mutex = Mutex.new
34
+ @sequence = 0
35
+ @last_progress_ns = nil
36
+ @fiber_id = nil
37
+ @started = false
38
+ @start_requested = false
39
+ @stop_requested = false
40
+ end
41
+
42
+ def start(schedule: Fiber.method(:schedule), sleeper: Kernel.method(:sleep))
43
+ raise RuntimeContractError, 'schedule must respond to call' unless schedule.respond_to?(:call)
44
+ raise RuntimeContractError, 'sleeper must respond to call' unless sleeper.respond_to?(:call)
45
+
46
+ @mutex.synchronize do
47
+ return self if @start_requested
48
+
49
+ @start_requested = true
50
+ end
51
+ schedule.call { run(sleeper) }
52
+ self
53
+ rescue StandardError
54
+ @mutex.synchronize { @start_requested = false }
55
+ raise
56
+ end
57
+
58
+ def tick
59
+ now_ns = @clock.monotonic_ns
60
+ @mutex.synchronize do
61
+ @sequence += 1
62
+ @last_progress_ns = now_ns
63
+ @fiber_id ||= Fiber.current.object_id
64
+ @started = true
65
+ end
66
+ @on_tick.call(self)
67
+ self
68
+ end
69
+
70
+ def request_stop
71
+ @mutex.synchronize { @stop_requested = true }
72
+ self
73
+ end
74
+
75
+ def snapshot
76
+ @mutex.synchronize do
77
+ Snapshot.new(
78
+ sequence: @sequence,
79
+ last_progress_ns: @last_progress_ns,
80
+ thread_id: owner_thread.object_id,
81
+ fiber_id: @fiber_id,
82
+ started: @started,
83
+ stop_requested: @stop_requested
84
+ )
85
+ end
86
+ end
87
+
88
+ def started?
89
+ @mutex.synchronize { @started }
90
+ end
91
+
92
+ def stop_requested?
93
+ @mutex.synchronize { @stop_requested }
94
+ end
95
+
96
+ private
97
+
98
+ def run(sleeper)
99
+ tick
100
+ loop do
101
+ break if stop_requested?
102
+
103
+ sleeper.call(@interval_ns.fdiv(1_000_000_000))
104
+ break if stop_requested?
105
+
106
+ tick
107
+ end
108
+ rescue StandardError => e
109
+ @on_error.call(self, e)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require_relative '../../errors'
6
+ require_relative '../event'
7
+ require_relative '../session'
8
+
9
+ module FiberAudit
10
+ module Runtime
11
+ module JSONL
12
+ # Versioned builders and validators stay together to keep one schema authority.
13
+ # rubocop:disable Metrics/ModuleLength
14
+ module Schema
15
+ SCHEMA_VERSION = '1.0'
16
+ RECORD_TYPES = %w[session_start event session_end].freeze
17
+ ENVELOPE_KEYS = %w[
18
+ schema_version record_type session_id sequence recorded_at monotonic_ns payload
19
+ ].freeze
20
+ POLICY_KEYS = %w[
21
+ redaction sampling_rate max_events_per_second max_events_per_session
22
+ max_record_bytes max_session_bytes fail_open
23
+ ].freeze
24
+ EVENT_KEYS = %w[
25
+ kind source duration_ns operation location execution_context thread_id fiber_id measurements
26
+ ].freeze
27
+ LOCATION_KEYS = %w[path line column].freeze
28
+ END_KEYS = %w[status events_observed events_emitted dropped internal_errors].freeze
29
+ DROPPED_KEYS = %w[
30
+ sampling rate_limit session_event_limit session_byte_limit record_size_limit
31
+ ].freeze
32
+ UUID = Validation::UUID
33
+ TIMESTAMP = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z\z/
34
+
35
+ module_function
36
+
37
+ def start_record(session)
38
+ require_type!(session, Session, 'session')
39
+ build_record(
40
+ record_type: 'session_start',
41
+ session_id: session.id,
42
+ sequence: 0,
43
+ recorded_at: session.started_at,
44
+ monotonic_ns: session.started_monotonic_ns,
45
+ payload: {
46
+ 'tool_version' => session.tool_version,
47
+ 'ruby_version' => session.ruby_version,
48
+ 'policy' => policy_payload(session.policy)
49
+ }
50
+ )
51
+ end
52
+
53
+ def event_record(session_id:, sequence:, event:)
54
+ require_type!(event, Event, 'event')
55
+ build_record(
56
+ record_type: 'event',
57
+ session_id: session_id,
58
+ sequence: sequence,
59
+ recorded_at: event.occurred_at,
60
+ monotonic_ns: event.monotonic_ns,
61
+ payload: {
62
+ 'kind' => event.kind.to_s,
63
+ 'source' => event.source.to_s,
64
+ 'duration_ns' => event.duration_ns,
65
+ 'operation' => event.operation,
66
+ 'location' => location_payload(event.location),
67
+ 'execution_context' => event.execution_context.to_s,
68
+ 'thread_id' => event.thread_id,
69
+ 'fiber_id' => event.fiber_id,
70
+ 'measurements' => event.measurements
71
+ }
72
+ )
73
+ end
74
+
75
+ def end_record(session_id:, sequence:, summary:)
76
+ require_type!(summary, SessionSummary, 'summary')
77
+ build_record(
78
+ record_type: 'session_end',
79
+ session_id: session_id,
80
+ sequence: sequence,
81
+ recorded_at: summary.ended_at,
82
+ monotonic_ns: summary.ended_monotonic_ns,
83
+ payload: {
84
+ 'status' => summary.status.to_s,
85
+ 'events_observed' => summary.events_observed,
86
+ 'events_emitted' => summary.events_emitted,
87
+ 'dropped' => {
88
+ 'sampling' => summary.sampled_out,
89
+ 'rate_limit' => summary.rate_limited,
90
+ 'session_event_limit' => summary.session_event_limited,
91
+ 'session_byte_limit' => summary.session_byte_limited,
92
+ 'record_size_limit' => summary.oversize
93
+ },
94
+ 'internal_errors' => summary.internal_errors
95
+ }
96
+ )
97
+ end
98
+
99
+ def validate!(record)
100
+ require_exact_keys!(record, ENVELOPE_KEYS, 'record')
101
+ require_value!(record['schema_version'] == SCHEMA_VERSION, 'schema_version must be 1.0')
102
+ require_value!(RECORD_TYPES.include?(record['record_type']), 'record_type is invalid')
103
+ validate_uuid!(record['session_id'])
104
+ validate_sequence!(record['sequence'], record['record_type'])
105
+ parse_timestamp(record['recorded_at'], 'recorded_at')
106
+ require_non_negative_integer!(record['monotonic_ns'], 'monotonic_ns')
107
+
108
+ case record['record_type']
109
+ when 'session_start' then validate_start_payload!(record['payload'])
110
+ when 'event' then validate_event_payload!(record)
111
+ when 'session_end' then validate_end_payload!(record)
112
+ end
113
+
114
+ record
115
+ rescue RuntimeContractError
116
+ raise
117
+ rescue StandardError => e
118
+ raise RuntimeContractError, "invalid runtime JSONL record: #{e.message}"
119
+ end
120
+
121
+ def dump(record, max_record_bytes:)
122
+ validate!(record)
123
+ unless max_record_bytes.is_a?(Integer) && max_record_bytes.positive?
124
+ raise RuntimeContractError, 'max_record_bytes must be a positive Integer'
125
+ end
126
+
127
+ json = "#{::JSON.generate(record)}\n"
128
+ if json.bytesize > max_record_bytes
129
+ raise RuntimeSafetyError,
130
+ "runtime JSONL record is #{json.bytesize} bytes; limit is #{max_record_bytes}"
131
+ end
132
+
133
+ json.freeze
134
+ rescue ::JSON::GeneratorError => e
135
+ raise RuntimeContractError, "runtime JSONL record is not JSON-safe: #{e.message}"
136
+ end
137
+
138
+ def build_record(record_type:, session_id:, sequence:, recorded_at:, monotonic_ns:, payload:)
139
+ record = {
140
+ 'schema_version' => SCHEMA_VERSION,
141
+ 'record_type' => record_type,
142
+ 'session_id' => session_id.is_a?(String) ? session_id.dup : session_id,
143
+ 'sequence' => sequence,
144
+ 'recorded_at' => format_time(recorded_at),
145
+ 'monotonic_ns' => monotonic_ns,
146
+ 'payload' => payload
147
+ }
148
+ validate!(record)
149
+ deep_freeze(record)
150
+ end
151
+ private_class_method :build_record
152
+
153
+ def policy_payload(policy)
154
+ {
155
+ 'redaction' => policy.redaction.to_s,
156
+ 'sampling_rate' => policy.sampling_rate,
157
+ 'max_events_per_second' => policy.max_events_per_second,
158
+ 'max_events_per_session' => policy.max_events_per_session,
159
+ 'max_record_bytes' => policy.max_record_bytes,
160
+ 'max_session_bytes' => policy.max_session_bytes,
161
+ 'fail_open' => policy.fail_open
162
+ }
163
+ end
164
+ private_class_method :policy_payload
165
+
166
+ def location_payload(location)
167
+ return unless location
168
+
169
+ { 'path' => location.path, 'line' => location.line, 'column' => location.column }
170
+ end
171
+ private_class_method :location_payload
172
+
173
+ def validate_start_payload!(payload)
174
+ require_exact_keys!(payload, %w[tool_version ruby_version policy], 'session_start payload')
175
+ Validation.string(payload['tool_version'], 'tool_version', max_bytes: 64)
176
+ Validation.string(payload['ruby_version'], 'ruby_version', max_bytes: 64)
177
+ require_exact_keys!(payload['policy'], POLICY_KEYS, 'policy')
178
+ values = payload['policy']
179
+ Policy.new(
180
+ redaction: values['redaction'],
181
+ sampling_rate: values['sampling_rate'],
182
+ max_events_per_second: values['max_events_per_second'],
183
+ max_events_per_session: values['max_events_per_session'],
184
+ max_record_bytes: values['max_record_bytes'],
185
+ max_session_bytes: values['max_session_bytes'],
186
+ fail_open: values['fail_open']
187
+ )
188
+ end
189
+ private_class_method :validate_start_payload!
190
+
191
+ def validate_event_payload!(record)
192
+ payload = record['payload']
193
+ require_exact_keys!(payload, EVENT_KEYS, 'event payload')
194
+ location = validate_location_payload!(payload['location'])
195
+ Event.new(
196
+ kind: payload['kind'],
197
+ source: payload['source'],
198
+ occurred_at: parse_timestamp(record['recorded_at'], 'recorded_at'),
199
+ monotonic_ns: record['monotonic_ns'],
200
+ duration_ns: payload['duration_ns'],
201
+ operation: payload['operation'],
202
+ location: location,
203
+ execution_context: payload['execution_context'],
204
+ thread_id: payload['thread_id'],
205
+ fiber_id: payload['fiber_id'],
206
+ measurements: payload['measurements']
207
+ )
208
+ end
209
+ private_class_method :validate_event_payload!
210
+
211
+ def validate_location_payload!(payload)
212
+ return if payload.nil?
213
+
214
+ require_exact_keys!(payload, LOCATION_KEYS, 'location')
215
+ Location.new(path: payload['path'], line: payload['line'], column: payload['column'])
216
+ end
217
+ private_class_method :validate_location_payload!
218
+
219
+ def validate_end_payload!(record)
220
+ payload = record['payload']
221
+ require_exact_keys!(payload, END_KEYS, 'session_end payload')
222
+ require_exact_keys!(payload['dropped'], DROPPED_KEYS, 'dropped')
223
+ dropped = payload['dropped']
224
+ SessionSummary.new(
225
+ ended_at: parse_timestamp(record['recorded_at'], 'recorded_at'),
226
+ ended_monotonic_ns: record['monotonic_ns'],
227
+ status: payload['status'],
228
+ events_observed: payload['events_observed'],
229
+ events_emitted: payload['events_emitted'],
230
+ sampled_out: dropped['sampling'],
231
+ rate_limited: dropped['rate_limit'],
232
+ session_event_limited: dropped['session_event_limit'],
233
+ session_byte_limited: dropped['session_byte_limit'],
234
+ oversize: dropped['record_size_limit'],
235
+ internal_errors: payload['internal_errors']
236
+ )
237
+ end
238
+ private_class_method :validate_end_payload!
239
+
240
+ def require_exact_keys!(value, expected, path)
241
+ raise RuntimeContractError, "#{path} must be an object" unless value.is_a?(Hash)
242
+
243
+ keys = value.keys
244
+ unknown = keys - expected
245
+ missing = expected - keys
246
+ raise RuntimeContractError, "unknown key #{unknown.first.inspect} at #{path}" unless unknown.empty?
247
+ raise RuntimeContractError, "missing key #{missing.first.inspect} at #{path}" unless missing.empty?
248
+ end
249
+ private_class_method :require_exact_keys!
250
+
251
+ def validate_uuid!(value)
252
+ require_value!(value.is_a?(String) && value.match?(UUID), 'session_id must be a canonical lowercase UUID')
253
+ end
254
+ private_class_method :validate_uuid!
255
+
256
+ def validate_sequence!(value, record_type)
257
+ minimum = record_type == 'session_start' ? 0 : 1
258
+ require_non_negative_integer!(value, 'sequence')
259
+ require_value!(value.zero?, 'session_start sequence must be 0') if record_type == 'session_start'
260
+ require_value!(value >= minimum, "#{record_type} sequence must be positive") unless record_type == 'session_start'
261
+ end
262
+ private_class_method :validate_sequence!
263
+
264
+ def parse_timestamp(value, field)
265
+ require_value!(
266
+ value.is_a?(String) && value.match?(TIMESTAMP),
267
+ "#{field} must be UTC RFC3339 with six fractional digits"
268
+ )
269
+ Time.iso8601(value)
270
+ rescue ArgumentError
271
+ raise RuntimeContractError, "#{field} is not a valid timestamp"
272
+ end
273
+ private_class_method :parse_timestamp
274
+
275
+ def format_time(value)
276
+ Validation.utc_time(value, 'recorded_at').iso8601(6)
277
+ end
278
+ private_class_method :format_time
279
+
280
+ def require_non_negative_integer!(value, field)
281
+ require_value!(value.is_a?(Integer) && value >= 0, "#{field} must be a non-negative Integer")
282
+ end
283
+ private_class_method :require_non_negative_integer!
284
+
285
+ def require_type!(value, type, field)
286
+ raise RuntimeContractError, "#{field} must be a #{type}" unless value.is_a?(type)
287
+ end
288
+ private_class_method :require_type!
289
+
290
+ def require_value!(condition, message)
291
+ raise RuntimeContractError, message unless condition
292
+ end
293
+ private_class_method :require_value!
294
+
295
+ def deep_freeze(value)
296
+ case value
297
+ when Hash
298
+ value.each do |key, entry|
299
+ deep_freeze(key)
300
+ deep_freeze(entry)
301
+ end
302
+ when Array
303
+ value.each { |entry| deep_freeze(entry) }
304
+ end
305
+ value.freeze
306
+ end
307
+ private_class_method :deep_freeze
308
+ end
309
+ # rubocop:enable Metrics/ModuleLength
310
+ end
311
+ end
312
+ end