overseer-testing-protocol 0.1.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 (33) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +19 -0
  3. data/README.md +75 -0
  4. data/SOURCE.json +6 -0
  5. data/bin/overseer-testing-conformance +53 -0
  6. data/docs/testing-control-implementation-guide.md +116 -0
  7. data/docs/testing-control-protocol.md +432 -0
  8. data/lib/overseer/testing_control/conformance/case_file.rb +120 -0
  9. data/lib/overseer/testing_control/conformance/http_transport.rb +114 -0
  10. data/lib/overseer/testing_control/conformance/report.rb +104 -0
  11. data/lib/overseer/testing_control/conformance/runner.rb +783 -0
  12. data/lib/overseer/testing_control/discovery.rb +79 -0
  13. data/lib/overseer/testing_control/json_subset_matcher.rb +120 -0
  14. data/lib/overseer/testing_control/protocol_v3.rb +298 -0
  15. data/lib/overseer/testing_control/redaction.rb +111 -0
  16. data/lib/overseer/testing_protocol.rb +9 -0
  17. data/protocol/testing-control/v3/conformance-case.schema.json +151 -0
  18. data/protocol/testing-control/v3/conformance-report.schema.json +255 -0
  19. data/protocol/testing-control/v3/fixtures/capabilities-response.json +147 -0
  20. data/protocol/testing-control/v3/fixtures/conformance-case.json +23 -0
  21. data/protocol/testing-control/v3/fixtures/conformance-report.json +88 -0
  22. data/protocol/testing-control/v3/fixtures/error-response.json +20 -0
  23. data/protocol/testing-control/v3/fixtures/manifest.json +13 -0
  24. data/protocol/testing-control/v3/fixtures/probe-request.json +20 -0
  25. data/protocol/testing-control/v3/fixtures/probe-response.json +22 -0
  26. data/protocol/testing-control/v3/fixtures/reset-response.json +15 -0
  27. data/protocol/testing-control/v3/fixtures/sink-query-request.json +26 -0
  28. data/protocol/testing-control/v3/fixtures/sink-query-response.json +39 -0
  29. data/protocol/testing-control/v3/fixtures/state-request.json +20 -0
  30. data/protocol/testing-control/v3/fixtures/state-response.json +23 -0
  31. data/protocol/testing-control/v3/openapi.yaml +343 -0
  32. data/protocol/testing-control/v3/schema.json +772 -0
  33. metadata +85 -0
@@ -0,0 +1,783 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'json_schemer'
5
+ require 'securerandom'
6
+ require 'time'
7
+
8
+ require_relative '../json_subset_matcher'
9
+
10
+ module Overseer
11
+ module TestingControl
12
+ module Conformance
13
+ class Runner
14
+ attr_reader :discovery
15
+
16
+ class DiscoveryRejected < StandardError; end
17
+
18
+ ExpectedIdentity = Data.define(:application_id, :application_version, :environment_id)
19
+ Dependencies = Data.define(:transport, :clock, :random)
20
+ DEFAULT_DEPENDENCIES = Dependencies.new(transport: nil, clock: Time, random: SecureRandom)
21
+ INITIAL_MAX_RESPONSE_BYTES = 16 * 1024 * 1024
22
+ ERROR_STATUSES = {
23
+ 'invalid-request' => 400,
24
+ 'capability-not-found' => 404,
25
+ 'protocol-version-unsupported' => 409,
26
+ 'reset-requires-restart' => 409,
27
+ 'payload-too-large' => 413,
28
+ 'schema-validation-failed' => 422,
29
+ 'limit-exceeded' => 422,
30
+ 'runtime-unavailable' => 503,
31
+ 'internal-error' => 500
32
+ }.freeze
33
+ RAW_EXCEPTION_PATTERN = %r{
34
+ <!DOCTYPE|<html|\bbacktrace\b|\bstack\s+trace\b|
35
+ /[A-Za-z0-9_./-]+\.(?:rb|js|py|java|go):\d+
36
+ }ix
37
+
38
+ # rubocop:disable Metrics/ParameterLists
39
+ def initialize(
40
+ origin:,
41
+ base_path:,
42
+ target_label:,
43
+ expected_identity:,
44
+ required_capabilities:,
45
+ cases:,
46
+ lifecycle:,
47
+ redaction_policy:,
48
+ restart: nil,
49
+ cancellation: nil,
50
+ progress: nil,
51
+ discovery_validator: nil,
52
+ dependencies: DEFAULT_DEPENDENCIES
53
+ )
54
+ @origin = HTTPTransport.origin!(origin)
55
+ @base_path = base_path
56
+ @target_label = target_label
57
+ @expected_identity = expected_identity
58
+ @required_capabilities = required_capabilities
59
+ @cases = cases&.fetch('cases', []) || []
60
+ @lifecycle = lifecycle
61
+ @redaction_policy = redaction_policy
62
+ @restart = restart
63
+ @cancellation = cancellation
64
+ @progress = progress
65
+ @discovery_validator = discovery_validator
66
+ @dependencies = dependencies
67
+ @transport = dependencies.transport || HTTPTransport.new(origin: @origin, base_path:)
68
+ @run_id = ProtocolV3.identifier!("conformance-#{dependencies.random.hex(8)}", label: 'conformance run ID')
69
+ @checks = []
70
+ @negative_responses = []
71
+ @response_contract_safe = true
72
+ @capabilities_document = nil
73
+ @runtime_data = nil
74
+ @identity = nil
75
+ @reset_ready = false
76
+ end
77
+ # rubocop:enable Metrics/ParameterLists
78
+
79
+ def call
80
+ started_at = timestamp
81
+ run_core_checks
82
+ run_capability_cases
83
+ completed_at = timestamp
84
+ report = {
85
+ 'schemaVersion' => '1',
86
+ 'protocolVersion' => ProtocolV3::VERSION,
87
+ 'target' => {
88
+ 'label' => safe_message(@target_label),
89
+ 'origin' => @origin,
90
+ 'sourceVersion' => @expected_identity.application_version
91
+ },
92
+ 'startedAt' => started_at,
93
+ 'completedAt' => completed_at,
94
+ 'lifecycle' => @lifecycle,
95
+ 'identity' => @identity,
96
+ 'capabilities' => report_capabilities,
97
+ 'checks' => @checks,
98
+ 'aggregateStatus' => @checks.all? { |check| check.fetch('status') == 'passed' } ? 'passed' : 'failed'
99
+ }
100
+ Report.validate!(report)
101
+ end
102
+
103
+ private
104
+
105
+ def run_core_checks
106
+ check('core.discovery') do
107
+ @capabilities_document = successful_exchange(
108
+ :get,
109
+ ProtocolV3::PATHS.fetch(:capabilities),
110
+ kind: :capabilities,
111
+ max_response_bytes: INITIAL_MAX_RESPONSE_BYTES
112
+ )
113
+ 'Discovered testing-control protocol v3 through the canonical capabilities endpoint.'
114
+ end
115
+ check('core.runtime-identity') { validate_runtime_identity }
116
+ check('core.capabilities') { validate_capabilities }
117
+ check('core.unsupported-version') { validate_unsupported_version }
118
+ check('core.malformed-json') { validate_malformed_json }
119
+ check('core.request-media-type') { validate_request_media_type }
120
+ check('core.unknown-arrangement') { validate_unknown_capability(:states, :state_request) }
121
+ check('core.unknown-observation') { validate_unknown_capability(:probes, :probe_request) }
122
+ check('core.schema-invalid-request') { validate_schema_invalid_request }
123
+ @reset_ready = check('core.reset') { perform_reset }
124
+ check('core.error-safety') { validate_error_safety }
125
+ check('core.response-contract') { validate_response_contract }
126
+ end
127
+
128
+ def validate_runtime_identity
129
+ require_capabilities!
130
+ data = @capabilities_document.fetch('data')
131
+ @identity = {
132
+ 'applicationId' => data.dig('application', 'id'),
133
+ 'applicationVersion' => data.dig('application', 'version'),
134
+ 'environmentId' => data.dig('environment', 'id')
135
+ }
136
+ expected = @expected_identity
137
+ mismatches = []
138
+ mismatches << 'application ID' unless @identity.fetch('applicationId') == expected.application_id
139
+ mismatches << 'application source version' unless
140
+ @identity.fetch('applicationVersion') == expected.application_version
141
+ mismatches << 'environment ID' unless @identity.fetch('environmentId') == expected.environment_id
142
+ raise CheckFailure.new('runtime-identity', "Runtime identity mismatch: #{mismatches.join(', ')}") unless
143
+ mismatches.empty?
144
+
145
+ 'Runtime application, source version, and environment identity match the external witness.'
146
+ end
147
+
148
+ def validate_capabilities
149
+ require_capabilities!
150
+ ProtocolV3.validate_capabilities!(@capabilities_document, required: @required_capabilities)
151
+ discovery = Discovery.build(@capabilities_document)
152
+ validate_discovery!(discovery)
153
+ @runtime_data = @capabilities_document.fetch('data')
154
+ @discovery = discovery
155
+ 'Capability advertisement and every product-owned JSON Schema are valid and bounded.'
156
+ rescue ProtocolV3::Error, Discovery::Error => e
157
+ raise CheckFailure.new('capability-advertisement', e.message)
158
+ end
159
+
160
+ def validate_discovery!(discovery)
161
+ @discovery_validator&.call(discovery)
162
+ rescue StandardError => e
163
+ raise DiscoveryRejected, e.message
164
+ end
165
+
166
+ def validate_unsupported_version
167
+ error_exchange(
168
+ :get,
169
+ ProtocolV3::PATHS.fetch(:capabilities),
170
+ code: 'protocol-version-unsupported',
171
+ version: '2',
172
+ max_response_bytes: negotiated_max_response_bytes
173
+ )
174
+ 'A non-v3 request fails explicitly with HTTP 409 and protocol-version-unsupported.'
175
+ end
176
+
177
+ def validate_malformed_json
178
+ error_exchange(
179
+ :post,
180
+ ProtocolV3::PATHS.fetch(:states),
181
+ code: 'invalid-request',
182
+ raw_body: '{',
183
+ max_response_bytes: negotiated_max_response_bytes
184
+ )
185
+ 'Malformed JSON fails with a bounded structured invalid-request response.'
186
+ end
187
+
188
+ def validate_request_media_type
189
+ body = request_envelope(
190
+ next_identifier,
191
+ data: {
192
+ 'capability' => { 'id' => 'overseer-conformance-unknown', 'version' => '1' },
193
+ 'input' => {}
194
+ }
195
+ )
196
+ error_exchange(
197
+ :post,
198
+ ProtocolV3::PATHS.fetch(:states),
199
+ code: 'invalid-request',
200
+ body:,
201
+ content_type: 'text/plain',
202
+ max_response_bytes: negotiated_max_response_bytes
203
+ )
204
+ 'A non-JSON request media type fails with a bounded structured invalid-request response.'
205
+ end
206
+
207
+ def validate_unknown_capability(path_key, kind)
208
+ path = ProtocolV3::PATHS.fetch(path_key)
209
+ body = request_envelope(
210
+ next_identifier,
211
+ data: {
212
+ 'capability' => { 'id' => 'overseer-conformance-unknown', 'version' => '1' },
213
+ 'input' => {}
214
+ }
215
+ )
216
+ ProtocolV3.validate_document!(kind, body)
217
+ error_exchange(
218
+ :post,
219
+ path,
220
+ code: 'capability-not-found',
221
+ body:,
222
+ max_response_bytes: negotiated_max_response_bytes
223
+ )
224
+ family = path_key == :states ? 'arrangement' : 'observation'
225
+ "An unknown #{family} capability fails with a structured capability-not-found response."
226
+ rescue ProtocolV3::Error => e
227
+ raise CheckFailure.new('request-envelope', e.message)
228
+ end
229
+
230
+ def validate_schema_invalid_request
231
+ unless @runtime_data
232
+ return 'No arrangement is advertised; product-payload schema validation is not applicable.'
233
+ end
234
+
235
+ capability = @runtime_data.fetch('states').first
236
+ return 'No arrangement is advertised; product-payload schema validation is not applicable.' unless capability
237
+
238
+ begin
239
+ ProtocolV3.validate_value!(capability.fetch('inputSchema'), {}, label: 'conformance invalid input')
240
+ return 'The first arrangement accepts an empty object; its schema has no safe generic negative value.'
241
+ rescue ProtocolV3::Error
242
+ # The request below is deliberately known to be invalid against the advertised schema.
243
+ end
244
+ reference = capability.slice('id', 'version')
245
+ body = request_envelope(next_identifier, data: { 'capability' => reference, 'input' => {} })
246
+ ProtocolV3.validate_document!(:state_request, body)
247
+ error_exchange(
248
+ :post,
249
+ ProtocolV3::PATHS.fetch(:states),
250
+ code: 'schema-validation-failed',
251
+ body:,
252
+ max_response_bytes: negotiated_max_response_bytes
253
+ )
254
+ 'A payload known to violate an advertised arrangement schema fails with schema-validation-failed.'
255
+ rescue ProtocolV3::Error => e
256
+ raise CheckFailure.new('request-envelope', e.message)
257
+ end
258
+
259
+ # rubocop:disable Metrics/CyclomaticComplexity
260
+ def perform_reset
261
+ require_runtime_data!
262
+ strategy = @runtime_data.dig('reset', 'strategy')
263
+ if strategy == 'in-process'
264
+ document = successful_exchange(
265
+ :put,
266
+ ProtocolV3::PATHS.fetch(:reset),
267
+ kind: :reset,
268
+ max_response_bytes: negotiated_max_response_bytes,
269
+ idempotency_key: "reset-#{next_identifier}"
270
+ )
271
+ data = document.fetch('data')
272
+ unless data['strategy'] == 'in-process' && data['sinksCleared'] == true
273
+ raise CheckFailure.new('reset', 'Reset response contradicts the advertised in-process strategy')
274
+ end
275
+
276
+ return 'In-process reset completed and reported cleared fake sinks.'
277
+ end
278
+
279
+ error_exchange(
280
+ :put,
281
+ ProtocolV3::PATHS.fetch(:reset),
282
+ code: 'reset-requires-restart',
283
+ max_response_bytes: negotiated_max_response_bytes,
284
+ idempotency_key: "reset-#{next_identifier}"
285
+ )
286
+ unless @restart
287
+ raise CheckFailure.new('runtime-lifecycle',
288
+ 'Runtime-restart reset requires a managed runtime owner')
289
+ end
290
+
291
+ replacement = @restart.call
292
+ apply_replacement!(replacement)
293
+ document = successful_exchange(
294
+ :get,
295
+ ProtocolV3::PATHS.fetch(:capabilities),
296
+ kind: :capabilities,
297
+ max_response_bytes: INITIAL_MAX_RESPONSE_BYTES
298
+ )
299
+ ProtocolV3.validate_capabilities!(document, required: @required_capabilities)
300
+ replacement_discovery = Discovery.build(document)
301
+ unless replacement_discovery.digest == @discovery.digest
302
+ raise CheckFailure.new('stable-discovery', 'Replacement runtime changed its stable discovery digest')
303
+ end
304
+ unless document.dig('data', 'reset', 'strategy') == 'runtime-restart'
305
+ raise CheckFailure.new('reset', 'Replacement runtime changed its advertised reset strategy')
306
+ end
307
+
308
+ @capabilities_document = document
309
+ @runtime_data = document.fetch('data')
310
+ validate_runtime_identity
311
+ 'Runtime-restart reset recreated, re-witnessed, and renegotiated the isolated runtime.'
312
+ rescue ProtocolV3::Error, Discovery::Error => e
313
+ raise CheckFailure.new('reset', e.message)
314
+ rescue CheckFailure
315
+ raise
316
+ rescue StandardError => e
317
+ raise CheckFailure.new('runtime-lifecycle', "Managed runtime restart failed (#{e.class.name})")
318
+ end
319
+ # rubocop:enable Metrics/CyclomaticComplexity
320
+
321
+ def validate_error_safety
322
+ raise CheckFailure.new('error-safety', 'No negative protocol response was available for safety validation') if
323
+ @negative_responses.empty?
324
+ unless @negative_responses.all? { |item| item.fetch(:structured) && !item.fetch(:raw_exception) }
325
+ raise CheckFailure.new('error-safety', 'A negative response exposed an invalid or framework-specific body')
326
+ end
327
+
328
+ 'Every exercised error was schema-valid, bounded, sanitized, and free of raw framework exception content.'
329
+ end
330
+
331
+ def validate_response_contract
332
+ unless @response_contract_safe
333
+ raise CheckFailure.new('response-contract',
334
+ 'One or more responses violated media-type, size, timeout, or envelope rules')
335
+ end
336
+
337
+ 'All exercised responses used application/json, stayed within byte/time bounds, and matched v3 envelopes.'
338
+ end
339
+
340
+ def run_capability_cases
341
+ @cases.each do |scenario|
342
+ scenario_id = scenario.fetch('id')
343
+ unless @runtime_data && @reset_ready
344
+ add_check(
345
+ "scenario.#{scenario_id}.prerequisite",
346
+ 'failed',
347
+ 'reset',
348
+ 'Scenario was not executed because discovery, capability validation, or reset failed.',
349
+ scenario_id:
350
+ )
351
+ next
352
+ end
353
+
354
+ run_capability_case(scenario)
355
+ end
356
+ end
357
+
358
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
359
+ def run_capability_case(scenario)
360
+ scenario_id = scenario.fetch('id')
361
+ if scenario.fetch('resetBeforeCase')
362
+ reset_ok = check("scenario.#{scenario_id}.reset", scenario_id:) { perform_reset }
363
+ return unless reset_ok
364
+ end
365
+
366
+ arrangement = nil
367
+ arranged = check("scenario.#{scenario_id}.arrangement", scenario_id:) do
368
+ arrangement = run_arrangement(scenario.fetch('arrangement'))
369
+ 'Arrangement matched its declared protocol-level expectation.'
370
+ end
371
+ return unless arranged && arrangement
372
+ return unless scenario['observation']
373
+
374
+ observation = nil
375
+ observed = check("scenario.#{scenario_id}.observation", scenario_id:) do
376
+ observation = run_observation(scenario.fetch('observation'), arrangement)
377
+ 'Observation matched its declared protocol-level expectation and advertised result schema.'
378
+ end
379
+ return unless observed && observation && scenario['expectedObservation']
380
+
381
+ check("scenario.#{scenario_id}.assertion", scenario_id:) do
382
+ validate_observation_assertion!(observation, scenario.fetch('expectedObservation'))
383
+ 'Observation result matched the trusted declarative assertion.'
384
+ end
385
+ end
386
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
387
+
388
+ def run_arrangement(operation)
389
+ reference = operation.fetch('capability')
390
+ input = operation.fetch('input')
391
+ expected = operation.fetch('expected')
392
+ capability = capability_for('states', reference)
393
+ if capability.nil? && expected.fetch('status') == 'success'
394
+ raise CheckFailure.new('unknown-capability', 'Trusted arrangement capability was not advertised')
395
+ end
396
+
397
+ validate_case_input!(capability, input, expected:, label: 'arrangement')
398
+ body = request_envelope(next_identifier, data: { 'capability' => reference, 'input' => input })
399
+ if expected.fetch('status') == 'error'
400
+ error_exchange(
401
+ :post,
402
+ ProtocolV3::PATHS.fetch(:states),
403
+ code: expected.fetch('errorCode'),
404
+ body:,
405
+ max_response_bytes: negotiated_max_response_bytes
406
+ )
407
+ return nil
408
+ end
409
+
410
+ document = successful_exchange(
411
+ :post,
412
+ ProtocolV3::PATHS.fetch(:states),
413
+ kind: :state_response,
414
+ body:,
415
+ max_response_bytes: negotiated_max_response_bytes,
416
+ idempotency_key: arrangement_idempotency_key(capability)
417
+ )
418
+ data = document.fetch('data')
419
+ validate_returned_capability!(data, reference, 'arrangement')
420
+ ProtocolV3.validate_value!(capability.fetch('outputSchema'), data.fetch('output'),
421
+ label: 'arrangement output')
422
+ data
423
+ rescue ProtocolV3::Error => e
424
+ raise CheckFailure.new('capability-schema', e.message)
425
+ end
426
+
427
+ def run_observation(operation, arrangement)
428
+ reference = operation.fetch('capability')
429
+ expected = operation.fetch('expected')
430
+ input = copy_json(operation.fetch('input'))
431
+ operation.fetch('inputFromArrangementIdentifiers', {}).each do |input_name, identifier_name|
432
+ identifiers = arrangement.fetch('identifiers')
433
+ unless identifiers.key?(identifier_name)
434
+ raise CheckFailure.new('case-binding', "Arrangement identifier #{identifier_name} is unavailable")
435
+ end
436
+
437
+ input[input_name] = identifiers.fetch(identifier_name)
438
+ end
439
+ capability = capability_for('probes', reference)
440
+ if capability.nil? && expected.fetch('status') == 'success'
441
+ raise CheckFailure.new('unknown-capability', 'Trusted observation capability was not advertised')
442
+ end
443
+
444
+ validate_case_input!(capability, input, expected:, label: 'observation')
445
+ body = request_envelope(next_identifier, data: { 'capability' => reference, 'input' => input })
446
+ if expected.fetch('status') == 'error'
447
+ error_exchange(
448
+ :post,
449
+ ProtocolV3::PATHS.fetch(:probes),
450
+ code: expected.fetch('errorCode'),
451
+ body:,
452
+ max_response_bytes: negotiated_max_response_bytes
453
+ )
454
+ return nil
455
+ end
456
+
457
+ document = successful_exchange(
458
+ :post,
459
+ ProtocolV3::PATHS.fetch(:probes),
460
+ kind: :probe_response,
461
+ body:,
462
+ max_response_bytes: negotiated_max_response_bytes
463
+ )
464
+ data = document.fetch('data')
465
+ validate_returned_capability!(data, reference, 'observation')
466
+ ProtocolV3.validate_value!(
467
+ capability.fetch('outputSchema'),
468
+ data.fetch('projection'),
469
+ label: 'observation result'
470
+ )
471
+ data.fetch('projection')
472
+ rescue ProtocolV3::Error => e
473
+ raise CheckFailure.new('capability-schema', e.message)
474
+ end
475
+
476
+ def validate_case_input!(capability, input, expected:, label:)
477
+ return unless capability
478
+
479
+ valid = JSONSchemer.schema(capability.fetch('inputSchema')).valid?(input)
480
+ if expected.fetch('status') == 'success' && !valid
481
+ raise CheckFailure.new('case-definition', "Trusted #{label} input violates its advertised schema")
482
+ end
483
+ return unless expected['errorCode'] == 'schema-validation-failed' && valid
484
+
485
+ raise CheckFailure.new('case-definition', "Trusted #{label} negative input is valid against its schema")
486
+ end
487
+
488
+ def validate_observation_assertion!(value, assertion)
489
+ if assertion.key?('subset')
490
+ result = JsonSubsetMatcher.call(expected: assertion.fetch('subset'), actual: value)
491
+ return if result.matched?
492
+
493
+ raise CheckFailure.new('scenario-assertion', 'Observation result does not contain the expected JSON subset')
494
+ end
495
+ errors = JSONSchemer.schema(assertion.fetch('schema')).validate(value).take(3)
496
+ return if errors.empty?
497
+
498
+ raise CheckFailure.new('scenario-assertion', 'Observation result does not satisfy the expected JSON Schema')
499
+ end
500
+
501
+ # rubocop:disable Metrics/ParameterLists
502
+ def successful_exchange(method, path, kind:, max_response_bytes:, body: nil, idempotency_key: nil)
503
+ _response, document = exchange(
504
+ method,
505
+ path,
506
+ expected_status: 200,
507
+ success_kind: kind,
508
+ max_response_bytes:,
509
+ body:,
510
+ idempotency_key:
511
+ )
512
+ document
513
+ end
514
+
515
+ def error_exchange(
516
+ method,
517
+ path,
518
+ code:,
519
+ max_response_bytes:,
520
+ version: ProtocolV3::VERSION,
521
+ body: nil,
522
+ raw_body: nil,
523
+ content_type: nil,
524
+ idempotency_key: nil
525
+ )
526
+ status = ERROR_STATUSES.fetch(code)
527
+ _response, document = exchange(
528
+ method,
529
+ path,
530
+ expected_status: status,
531
+ success_kind: nil,
532
+ expected_error_code: code,
533
+ max_response_bytes:,
534
+ version:,
535
+ body:,
536
+ raw_body:,
537
+ content_type:,
538
+ idempotency_key:
539
+ )
540
+ document
541
+ end
542
+ # rubocop:enable Metrics/ParameterLists
543
+
544
+ # HTTP conformance deliberately evaluates the whole bounded response contract in one exchange.
545
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity
546
+ def exchange(
547
+ method,
548
+ path,
549
+ expected_status:,
550
+ success_kind:,
551
+ max_response_bytes:,
552
+ expected_error_code: nil,
553
+ version: ProtocolV3::VERSION,
554
+ body: nil,
555
+ raw_body: nil,
556
+ content_type: nil,
557
+ idempotency_key: nil
558
+ )
559
+ correlation_id = body&.dig('meta', 'correlationId') || next_identifier
560
+ encoded = raw_body || (JSON.generate(body) if body)
561
+ response = @transport.request(
562
+ method:,
563
+ path:,
564
+ headers: request_headers(
565
+ correlation_id:,
566
+ version:,
567
+ content: !encoded.nil?,
568
+ content_type:,
569
+ idempotency_key:
570
+ ),
571
+ body: encoded,
572
+ max_response_bytes:
573
+ )
574
+ raw_exception = response.body.match?(RAW_EXCEPTION_PATTERN)
575
+ content_type = header_value(response.headers, 'Content-Type')
576
+ content_type = content_type.split(';', 2).first.strip.downcase if content_type
577
+ raise CheckFailure.new('response-media-type', 'Response media type must be application/json') unless
578
+ content_type == 'application/json'
579
+
580
+ document = JSON.parse(response.body)
581
+ ProtocolV3.validate_response_headers!(response.headers, correlation_id:)
582
+ kind = response.status.between?(200, 299) ? success_kind : :error
583
+ raise CheckFailure.new('http-status', "Unexpected successful HTTP #{response.status}") unless kind
584
+
585
+ ProtocolV3.validate_document!(kind, document)
586
+ ProtocolV3.validate_meta!(document, run_id: @run_id, correlation_id:)
587
+ if response.status != expected_status
588
+ raise CheckFailure.new(
589
+ 'http-status',
590
+ "Expected HTTP #{expected_status}; received HTTP #{response.status}"
591
+ )
592
+ end
593
+ if expected_error_code && document.dig('errors', 0, 'code') != expected_error_code
594
+ raise CheckFailure.new(
595
+ 'error-code',
596
+ "Expected #{expected_error_code}; received #{document.dig('errors', 0, 'code') || 'no error code'}"
597
+ )
598
+ end
599
+ if response.status >= 400
600
+ @negative_responses << { structured: true, raw_exception: }
601
+ elsif raw_exception
602
+ raise CheckFailure.new('error-safety', 'Successful response contains framework exception content')
603
+ end
604
+ [response, document]
605
+ rescue HTTPTransport::TimeoutError => e
606
+ @response_contract_safe = false
607
+ raise CheckFailure.new('timeout', e.message)
608
+ rescue HTTPTransport::ResponseTooLarge => e
609
+ @response_contract_safe = false
610
+ raise CheckFailure.new('response-size', e.message)
611
+ rescue HTTPTransport::Error => e
612
+ @response_contract_safe = false
613
+ raise CheckFailure.new('transport', e.message)
614
+ rescue JSON::ParserError
615
+ @response_contract_safe = false
616
+ raise CheckFailure.new('response-json', 'Response body is not valid JSON')
617
+ rescue ProtocolV3::Error => e
618
+ @response_contract_safe = false
619
+ raise CheckFailure.new('response-envelope', e.message)
620
+ rescue CheckFailure => e
621
+ @response_contract_safe = false if %w[response-media-type response-envelope
622
+ response-json].include?(e.category)
623
+ raise
624
+ end
625
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity
626
+
627
+ def request_headers(correlation_id:, version:, content:, content_type: nil, idempotency_key: nil)
628
+ headers = ProtocolV3::HEADERS
629
+ {
630
+ 'Accept' => 'application/json',
631
+ **(content ? { 'Content-Type' => content_type || 'application/json' } : {}),
632
+ headers.fetch(:version) => version,
633
+ headers.fetch(:run_id) => @run_id,
634
+ headers.fetch(:correlation_id) => correlation_id,
635
+ **(idempotency_key ? { headers.fetch(:idempotency_key) => idempotency_key } : {})
636
+ }
637
+ end
638
+
639
+ def request_envelope(correlation_id, data:)
640
+ {
641
+ 'protocol' => { 'name' => ProtocolV3::NAME, 'version' => ProtocolV3::VERSION },
642
+ 'meta' => { 'runId' => @run_id, 'correlationId' => correlation_id },
643
+ 'data' => data
644
+ }
645
+ end
646
+
647
+ def apply_replacement!(replacement)
648
+ unless replacement.is_a?(Hash) && replacement[:origin] && replacement[:expected_identity]
649
+ raise CheckFailure.new('runtime-lifecycle', 'Managed runtime restart returned no replacement witness')
650
+ end
651
+
652
+ @origin = HTTPTransport.origin!(replacement.fetch(:origin))
653
+ @expected_identity = replacement.fetch(:expected_identity)
654
+ @transport = HTTPTransport.new(origin: @origin, base_path: @base_path) unless @dependencies.transport
655
+ end
656
+
657
+ def capability_for(family, reference)
658
+ @runtime_data.fetch(family).find do |item|
659
+ item['id'] == reference.fetch('id') && item['version'] == reference.fetch('version')
660
+ end
661
+ end
662
+
663
+ def arrangement_idempotency_key(capability)
664
+ return unless capability&.fetch('idempotency') == 'required'
665
+
666
+ "case-#{next_identifier}"
667
+ end
668
+
669
+ def validate_returned_capability!(data, expected, label)
670
+ return if data.fetch('capability') == expected
671
+
672
+ raise CheckFailure.new('capability-mismatch', "#{label.capitalize} returned a different capability")
673
+ end
674
+
675
+ def report_capabilities
676
+ return unless @runtime_data
677
+
678
+ {
679
+ 'resetStrategy' => @runtime_data.dig('reset', 'strategy'),
680
+ 'arrangements' => @runtime_data.fetch('states').map do |item|
681
+ report_capability(item, input: 'inputSchema', output: 'outputSchema')
682
+ end,
683
+ 'observations' => @runtime_data.fetch('probes').map do |item|
684
+ report_capability(item, input: 'inputSchema', output: 'outputSchema')
685
+ end,
686
+ 'sinks' => @runtime_data.fetch('sinks').map do |item|
687
+ report_capability(item, input: 'queryInputSchema', output: 'recordSchema')
688
+ end
689
+ }
690
+ end
691
+
692
+ def report_capability(item, input:, output:)
693
+ {
694
+ 'id' => item.fetch('id'),
695
+ 'version' => item.fetch('version'),
696
+ 'inputSchema' => Redaction.body(item.fetch(input), @redaction_policy),
697
+ 'outputSchema' => Redaction.body(item.fetch(output), @redaction_policy)
698
+ }
699
+ end
700
+
701
+ def negotiated_max_response_bytes
702
+ @runtime_data&.dig('limits', 'maxResponseBytes') || INITIAL_MAX_RESPONSE_BYTES
703
+ end
704
+
705
+ def require_capabilities!
706
+ return if @capabilities_document
707
+
708
+ raise CheckFailure.new('prerequisite', 'Capability discovery did not complete')
709
+ end
710
+
711
+ def require_runtime_data!
712
+ return if @runtime_data
713
+
714
+ raise CheckFailure.new('prerequisite', 'Capability validation did not complete')
715
+ end
716
+
717
+ def check(id, scenario_id: nil)
718
+ raise CheckFailure.new('cancelled', 'Conformance was cancelled') if @cancellation&.call
719
+
720
+ @progress&.call(phase: conformance_phase(id))
721
+ message = yield
722
+ add_check(id, 'passed', 'protocol', message, scenario_id:)
723
+ @progress&.call(phase: conformance_phase(id))
724
+ true
725
+ rescue DiscoveryRejected
726
+ raise
727
+ rescue CheckFailure => e
728
+ add_check(id, 'failed', e.category, e.message, scenario_id:)
729
+ false
730
+ rescue StandardError => e
731
+ add_check(id, 'failed', 'runner', "Conformance check failed safely (#{e.class.name})", scenario_id:)
732
+ false
733
+ end
734
+
735
+ def add_check(id, status, category, message, scenario_id: nil)
736
+ @checks << {
737
+ 'id' => id,
738
+ 'status' => status,
739
+ 'category' => category,
740
+ 'message' => safe_message(message),
741
+ **(scenario_id ? { 'scenarioId' => scenario_id } : {})
742
+ }
743
+ end
744
+
745
+ def conformance_phase(id)
746
+ %w[core.discovery core.runtime-identity core.capabilities].include?(id) ? 'discovery' : 'conformance'
747
+ end
748
+
749
+ def safe_message(value)
750
+ Redaction.string(value.to_s).byteslice(0, 500).to_s.scrub
751
+ end
752
+
753
+ def next_identifier
754
+ ProtocolV3.identifier!(@dependencies.random.uuid, label: 'conformance correlation ID')
755
+ end
756
+
757
+ def timestamp
758
+ @dependencies.clock.now.utc.iso8601(3)
759
+ end
760
+
761
+ def copy_json(value)
762
+ JSON.parse(JSON.generate(value))
763
+ end
764
+
765
+ def header_value(headers, name)
766
+ headers.each do |candidate, value|
767
+ return Array(value).first if candidate.to_s.casecmp?(name)
768
+ end
769
+ nil
770
+ end
771
+
772
+ class CheckFailure < StandardError
773
+ attr_reader :category
774
+
775
+ def initialize(category, message)
776
+ @category = category
777
+ super(message)
778
+ end
779
+ end
780
+ end
781
+ end
782
+ end
783
+ end