Class: RailVerdict::Receipt
- Inherits:
-
Object
- Object
- RailVerdict::Receipt
- 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
-
#document ⇒ Object
readonly
Returns the value of attribute document.
Class Method Summary collapse
-
.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.
- .deep_freeze(value) ⇒ Object
-
.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.
- .id_for(payload) ⇒ Object
- .load(path) ⇒ Object
-
.parse(text) ⇒ Object
Parses and structurally validates a receipt document.
- .parse_document(document) ⇒ Object
-
.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.
Instance Method Summary collapse
- #completion_status ⇒ Object
- #gate ⇒ Object
-
#initialize(document) ⇒ Receipt
constructor
A new instance of Receipt.
- #receipt_id ⇒ Object
- #repository_state_projection ⇒ Object
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
#document ⇒ Object (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.
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: (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_status ⇒ Object
225 226 227 |
# File 'lib/rail_verdict/receipt.rb', line 225 def completion_status @document.dig("gate_projection", "completion_status") end |
#gate ⇒ Object
221 222 223 |
# File 'lib/rail_verdict/receipt.rb', line 221 def gate @document.dig("gate_projection", "gate") end |
#receipt_id ⇒ Object
217 218 219 |
# File 'lib/rail_verdict/receipt.rb', line 217 def receipt_id @document.fetch("receipt_id") end |
#repository_state_projection ⇒ Object
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 |