Class: RailVerdict::Receipt

Inherits:
Object
  • Object
show all
Defined in:
lib/rail_verdict/receipt.rb

Defined Under Namespace

Classes: BuildError, Validation

Constant Summary collapse

SCHEMA_VERSION =
"1.0"
ID_PATTERN =
/\Asha256:[0-9a-f]{64}\z/
MAX_DOCUMENT_BYTES =
256 * 1024
VOLATILE_TEST_KEYS =
%w[duration_seconds seed].freeze
FRESH =
"fresh"
STALE =
"stale"
INVALID =
"invalid"
UNAVAILABLE =
"unavailable"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(document) ⇒ Receipt

Returns a new instance of Receipt.



213
214
215
# File 'lib/rail_verdict/receipt.rb', line 213

def initialize(document)
  @document = document
end

Instance Attribute Details

#documentObject (readonly)

Returns the value of attribute document.



28
29
30
# File 'lib/rail_verdict/receipt.rb', line 28

def document
  @document
end

Class Method Details

.build(outcome:, railverdict_version: RailVerdict::VERSION, pr_intelligence_document: nil, repair_packet_id: nil, environment_ruby_version: RUBY_VERSION, environment_ruby_engine: RUBY_ENGINE) ⇒ Object

Builds a Verification Receipt v1 document from a guarded Check outcome. Fails closed (BuildError with a deterministic code) when repository state cannot be proven stable across the verification.

Raises:



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/rail_verdict/receipt.rb', line 33

def self.build(outcome:, railverdict_version: RailVerdict::VERSION, pr_intelligence_document: nil, repair_packet_id: nil, environment_ruby_version: RUBY_VERSION, environment_ruby_engine: RUBY_ENGINE)
  result = outcome&.result
  raise BuildError.new(:receipt_unavailable, "verification outcome is required") if result.nil?

  pre = outcome.repository_state_pre
  post = outcome.repository_state_post
  unless pre && pre.available? && post && post.available?
    reason = [pre, post].compact.map(&:unavailable_reason).compact.first || :repository_state_unavailable
    raise BuildError.new(:repository_state_unavailable, "repository state could not be determined: #{reason}")
  end
  if pre.digest != post.digest
    raise BuildError.new(:repository_changed_during_verification, "repository changed while verification was running")
  end

  components = post.components
  changed_scope = changed_scope_of(result)
  payload = {
    "schema_version" => SCHEMA_VERSION,
    "railverdict_version" => railverdict_version.to_s,
    "environment" => {
      "ruby_engine" => environment_ruby_engine.to_s,
      "ruby_version" => environment_ruby_version.to_s,
      "analyzer_versions" => sorted_analyzer_versions(outcome)
    },
    "verification_mode" => changed_scope ? "changed" : "full",
    "changed_scope" => changed_scope,
    "repository_state" => {
      "head" => components.fetch("head"),
      "index_digest" => components.fetch("index_digest"),
      "worktree_digest" => components.fetch("worktree_digest"),
      "configuration_digest" => components.fetch("configuration_digest"),
      "baseline_digest" => components.fetch("baseline_digest"),
      "waivers_digest" => components.fetch("waivers_digest")
    },
    "gate_projection" => gate_projection(result),
    "pr_intelligence" => pr_intelligence_binding(pr_intelligence_document),
    "repair" => repair_binding(repair_packet_id)
  }
  document = payload.merge("receipt_id" => id_for(payload))
  errors = SchemaValidator.validate_receipt(document)
  raise BuildError.new(:receipt_schema_violation, "receipt failed its own schema: #{errors.join('; ')}") unless errors.empty?

  deep_freeze(document)
end

.deep_freeze(value) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/rail_verdict/receipt.rb', line 78

def self.deep_freeze(value)
  case value
  when Hash
    value.each { |key, child| key.freeze if key.is_a?(String); deep_freeze(child) }
    value.freeze
  when Array
    value.each { |child| deep_freeze(child) }
    value.freeze
  when String
    value.freeze
  else
    value
  end
end

.evaluate(document_text, current_state: nil, repository_root: nil, configuration_paths: nil, railverdict_version: RailVerdict::VERSION, environment_ruby_version: RUBY_VERSION, environment_ruby_engine: RUBY_ENGINE, current_analyzer_versions: nil, current_environment: nil) ⇒ Object

Full black-box evaluation of a serialized receipt document against the current state: integrity first (invalid), then freshness. Returns [validation_document, receipt_or_nil]. When current_environment/current_state are not supplied but repository_root is, they are independently observed via the canonical VerificationIdentity.



262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
# File 'lib/rail_verdict/receipt.rb', line 262

def self.evaluate(document_text, current_state: nil, repository_root: nil, configuration_paths: nil, railverdict_version: RailVerdict::VERSION, environment_ruby_version: RUBY_VERSION, environment_ruby_engine: RUBY_ENGINE, current_analyzer_versions: nil, current_environment: nil)
  receipt, reason = parse(document_text)
  if reason
    return [validation_document(
      Validation.new(status: INVALID, reasons: [reason.to_s], gate: nil, completion_status: nil, current_repository_digest: nil, current_environment_digest: nil),
      receipt_id: embedded_receipt_id(document_text)
    ), nil]
  end

  # Canonical re-observation if not supplied (fail-closed trust invariant)
  if current_state.nil? && repository_root
    begin
      root_real = File.realpath(repository_root)
      paths = configuration_paths || Check.effective_input_paths(root: root_real, config_path: File.join(root_real, ".railverdict.yml"))
      current_state = RepositoryState.capture(repository_root: root_real, configuration_paths: paths)
    rescue StandardError
      current_state = RepositoryState.unavailable(:repository_root_unavailable)
    end
  end

  if current_environment.nil? && current_analyzer_versions.nil? && repository_root
    begin
      root_real = File.realpath(repository_root || Dir.pwd)
      stored_analyzer_keys = receipt.document.dig("environment", "analyzer_versions")&.keys || []
      # Resolve configuration for relevant probing
      config = nil
      begin
        paths = configuration_paths || Check.effective_input_paths(root: root_real, config_path: File.join(root_real, ".railverdict.yml"))
        cfg_path = paths[:config]
        config = Configuration.load(cfg_path) if cfg_path && File.file?(cfg_path)
      rescue StandardError
        config = nil
      end
      current_environment = VerificationEnvironment.capture_for_receipt(stored_analyzer_keys, repository_root: root_real, configuration: config)
    rescue StandardError
      current_environment = VerificationEnvironment.new(
        railverdict_version: RailVerdict::VERSION.to_s,
        ruby_engine: RUBY_ENGINE.to_s,
        ruby_version: RUBY_VERSION.to_s,
        analyzer_versions: {},
        digest: nil,
        unavailable_reason: "environment_capture_failed",
        available: false
      )
    end
  elsif current_analyzer_versions.is_a?(Hash) && current_environment.nil?
    # Legacy caller supplied analyzer versions hash directly -> wrap as environment
    current_environment = VerificationEnvironment.new(
      railverdict_version: railverdict_version.to_s,
      ruby_engine: environment_ruby_engine.to_s,
      ruby_version: environment_ruby_version.to_s,
      analyzer_versions: current_analyzer_versions.sort.to_h.transform_values(&:to_s),
      digest: nil,
      available: true
    )
  end

  validation = validate_freshness(
    receipt: receipt,
    current_state: current_state,
    railverdict_version: railverdict_version,
    environment_ruby_version: environment_ruby_version,
    environment_ruby_engine: environment_ruby_engine,
    current_analyzer_versions: current_analyzer_versions,
    current_environment: current_environment
  )
  [validation_document(validation, receipt_id: receipt.receipt_id), receipt]
end

.id_for(payload) ⇒ Object



93
94
95
# File 'lib/rail_verdict/receipt.rb', line 93

def self.id_for(payload)
  "sha256:#{Digest::SHA256.hexdigest(CanonicalJSON.generate(payload))}"
end

.load(path) ⇒ Object



204
205
206
207
208
209
210
211
# File 'lib/rail_verdict/receipt.rb', line 204

def self.load(path)
  text = begin
    File.binread(path)
  rescue StandardError
    return [nil, :receipt_unreadable]
  end
  parse(text)
end

.parse(text) ⇒ Object

Parses and structurally validates a receipt document. Returns [receipt_or_nil, invalid_reason_code_or_nil].



172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/rail_verdict/receipt.rb', line 172

def self.parse(text)
  if text.bytesize > MAX_DOCUMENT_BYTES
    return [nil, :receipt_too_large]
  end

  document = begin
    JSON.parse(text)
  rescue JSON::ParserError
    return [nil, :receipt_malformed]
  end
  parse_document(document)
end

.parse_document(document) ⇒ Object



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/rail_verdict/receipt.rb', line 185

def self.parse_document(document)
  unless document.is_a?(Hash)
    return [nil, :receipt_malformed]
  end
  unless document["schema_version"] == SCHEMA_VERSION
    return [nil, :incompatible_receipt_version]
  end

  errors = SchemaValidator.validate_receipt(document)
  return [nil, :receipt_schema_invalid] unless errors.empty?

  stored_id = document["receipt_id"]
  identity_payload = document.reject { |key, _| key == "receipt_id" }
  expected = id_for(identity_payload)
  return [nil, :receipt_integrity_failed] unless stored_id == expected

  [new(document.freeze), nil]
end

.validate_freshness(receipt:, current_state:, railverdict_version: RailVerdict::VERSION, environment_ruby_version: RUBY_VERSION, environment_ruby_engine: RUBY_ENGINE, current_analyzer_versions: nil, current_environment: nil) ⇒ Object

Validates a parsed receipt against the current observable state. This is the ONE canonical freshness evaluator — CLI and MCP must delegate here. It independently requires current_state and current_environment to be observed, never trusting receipt-provided values as current observation.



364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
# File 'lib/rail_verdict/receipt.rb', line 364

def self.validate_freshness(receipt:, current_state:, railverdict_version: RailVerdict::VERSION, environment_ruby_version: RUBY_VERSION, environment_ruby_engine: RUBY_ENGINE, current_analyzer_versions: nil, current_environment: nil)
  # If current_environment was supplied via legacy analyzer_versions hash, normalize
  if current_environment.nil? && current_analyzer_versions.is_a?(Hash) && !current_analyzer_versions.empty?
    current_environment = VerificationEnvironment.new(
      railverdict_version: railverdict_version.to_s,
      ruby_engine: environment_ruby_engine.to_s,
      ruby_version: environment_ruby_version.to_s,
      analyzer_versions: current_analyzer_versions.sort.to_h.transform_values(&:to_s),
      digest: nil,
      available: true
    )
  end

  unless current_state&.available?
    reason = current_state&.unavailable_reason || :repository_state_unavailable
    return Validation.new(
      status: UNAVAILABLE,
      reasons: ["repository_state_unavailable:#{reason}"],
      gate: receipt&.gate,
      completion_status: receipt&.completion_status,
      current_repository_digest: nil,
      current_environment_digest: nil
    )
  end

  # Environment must be observable fail-closed
  if current_environment && !current_environment.available?
    return Validation.new(
      status: UNAVAILABLE,
      reasons: ["analyzer_version_unobservable:#{current_environment.unavailable_reason}"],
      gate: receipt.gate,
      completion_status: receipt.completion_status,
      current_repository_digest: current_state.digest,
      current_environment_digest: nil
    )
  end

  stored = receipt.repository_state_projection.fetch("components")
  current = current_state.components
  reasons = []
  reasons << "head_changed" if stored.fetch("head") != current.fetch("head")
  reasons << "index_changed" if stored.fetch("index_digest") != current.fetch("index_digest")
  reasons << "worktree_changed" if stored.fetch("worktree_digest") != current.fetch("worktree_digest")
  reasons << "configuration_changed" if stored.fetch("configuration_digest") != current.fetch("configuration_digest")
  reasons << "baseline_changed" if stored.fetch("baseline_digest") != current.fetch("baseline_digest")
  reasons << "waivers_changed" if stored.fetch("waivers_digest") != current.fetch("waivers_digest")

  environment = receipt.document.fetch("environment")
  # RailVerdict version is always checked against current (not receipt-provided)
  current_rv = current_environment ? current_environment.railverdict_version.to_s : railverdict_version.to_s
  reasons << "railverdict_version_changed" if receipt.document.fetch("railverdict_version") != current_rv

  # Ruby version/engine: if environment observable, use it; else use supplied
  current_ruby_version = current_environment ? current_environment.ruby_version.to_s : environment_ruby_version.to_s
  current_ruby_engine = current_environment ? current_environment.ruby_engine.to_s : environment_ruby_engine.to_s
  # Ruby engine: receipt may not have it (1.2 compat) — if missing, treat as mismatch if current engine is present
  stored_ruby_engine = environment["ruby_engine"]
  if stored_ruby_engine.nil?
    # 1.2 receipt without engine: stale if we now track engine and it differs from default? Keep fresh for compat unless engine is not ruby/mri? For deterministic portable contract, we consider missing engine as stale only if current engine != "ruby"
    # To avoid breaking 1.2 compat trivially, we only flag when env explicitly requires engine and receipt lacks it? Current spec says 1.2 receipts should be valid but stale when env stronger — we will flag as stale to surface drift
    # For minimal breakage, do not flag missing engine as stale automatically; document as known limitation. Only check if receipt has engine.
  else
    reasons << "ruby_engine_changed" if stored_ruby_engine.to_s != current_ruby_engine
  end
  if environment.fetch("ruby_version") != current_ruby_version
    reasons << "ruby_version_changed"
  end

  # Analyzer environment: only relevant analyzers (those in receipt)
  stored_analyzers = environment.fetch("analyzer_versions")
  if stored_analyzers.is_a?(Hash) && !stored_analyzers.empty?
    current_analyzers = if current_environment
                          # Only compare relevant keys
                          filtered = current_environment.analyzer_versions.select { |k, _| stored_analyzers.key?(k) }
                          # Also include any stored key missing in current -> drift (probed enabled set may have disabled it)
                          # If stored key not in current, we need to probe it specifically; for now treat missing as stale
                          stored_analyzers.keys.each do |k|
                            filtered[k] ||= "__missing__"
                          end
                          filtered
                        else
                          current_analyzer_versions.is_a?(Hash) ? current_analyzer_versions.sort.to_h.transform_values(&:to_s) : nil
                        end
    if current_analyzers
      # Detect unknown in current as unavailable handled above; now compare relevant
      relevant_stored = stored_analyzers.sort.to_h.transform_values(&:to_s)
      relevant_current = current_analyzers.sort.to_h.transform_values(&:to_s)
      # If any stored "unknown" exists, treat as unavailable (fail-closed)
      if relevant_stored.values.include?("unknown") || relevant_current.values.include?("unknown")
        return Validation.new(
          status: UNAVAILABLE,
          reasons: ["analyzer_version_unobservable:unknown_or_missing"],
          gate: receipt.gate,
          completion_status: receipt.completion_status,
          current_repository_digest: current_state.digest,
          current_environment_digest: current_environment&.digest
        )
      end
      # Missing relevant analyzer in current env is drift -> stale, not unavailable
      if relevant_current.values.include?("__missing__")
        reasons << "analyzer_environment_changed"
      elsif relevant_stored != relevant_current
        reasons << "analyzer_environment_changed"
      end
    end
  end

  status = reasons.empty? ? FRESH : STALE
  Validation.new(
    status: status,
    reasons: reasons.sort,
    gate: receipt.gate,
    completion_status: receipt.completion_status,
    current_repository_digest: current_state.digest,
    current_environment_digest: current_environment&.digest
  )
end

Instance Method Details

#completion_statusObject



225
226
227
# File 'lib/rail_verdict/receipt.rb', line 225

def completion_status
  @document.dig("gate_projection", "completion_status")
end

#gateObject



221
222
223
# File 'lib/rail_verdict/receipt.rb', line 221

def gate
  @document.dig("gate_projection", "gate")
end

#receipt_idObject



217
218
219
# File 'lib/rail_verdict/receipt.rb', line 217

def receipt_id
  @document.fetch("receipt_id")
end

#repository_state_projectionObject



229
230
231
232
233
234
235
236
# File 'lib/rail_verdict/receipt.rb', line 229

def repository_state_projection
  components = state_components_of(@document.fetch("repository_state"))
  {
    "schema_version" => RepositoryState::SCHEMA_VERSION,
    "digest" => "sha256:#{Digest::SHA256.hexdigest(CanonicalJSON.generate(components))}",
    "components" => components
  }
end