Class: RailVerdict::Handoff

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

Defined Under Namespace

Classes: BuildError

Constant Summary collapse

SCHEMA_VERSION =
"1.0"
ID_PATTERN =
/\Asha256:[0-9a-f]{64}\z/
MAX_DOCUMENT_BYTES =
256 * 1024

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Attribute Details

#documentObject (readonly)

Returns the value of attribute document.



23
24
25
# File 'lib/rail_verdict/handoff.rb', line 23

def document
  @document
end

Class Method Details

.build(receipt:, evidence_set:, railverdict_version: RailVerdict::VERSION, evidence_provenance: nil, source_scope: nil) ⇒ Object

Raises:



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/rail_verdict/handoff.rb', line 25

def self.build(receipt:, evidence_set:, railverdict_version: RailVerdict::VERSION, evidence_provenance: nil, source_scope: nil)
  raise BuildError.new(:handoff_unavailable, "receipt is required") if receipt.nil? || !receipt.is_a?(Hash)
  raise BuildError.new(:handoff_unavailable, "evidence_set is required") if evidence_set.nil? || !evidence_set.is_a?(Hash)

  # Validate receipt structurally (reuse Receipt validation without re-persisting)
  errors = SchemaValidator.validate_receipt(receipt)
  raise BuildError.new(:receipt_schema_invalid, errors.join("; ")) unless errors.empty?

  payload = {
    "schema_version" => SCHEMA_VERSION,
    "railverdict_version" => railverdict_version.to_s,
    "receipt" => receipt,
    "evidence_set" => normalize_evidence_set(evidence_set)
  }
  payload["evidence_provenance"] = normalize_provenance(evidence_provenance) if evidence_provenance
  payload["source_scope"] = normalize_scope(source_scope) if source_scope

  document = payload.merge("handoff_id" => id_for(payload))
  errors = SchemaValidator.validate_handoff(document)
  raise BuildError.new(:handoff_schema_violation, errors.join("; ")) unless errors.empty?
  raise BuildError.new(:handoff_too_large, "handoff exceeds #{MAX_DOCUMENT_BYTES} bytes") if JSON.generate(document).bytesize > MAX_DOCUMENT_BYTES

  deep_freeze(document)
end

.deep_freeze(value) ⇒ Object



95
96
97
98
99
100
101
102
# File 'lib/rail_verdict/handoff.rb', line 95

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

.id_for(payload) ⇒ Object



50
51
52
# File 'lib/rail_verdict/handoff.rb', line 50

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

.parse(text) ⇒ Object



104
105
106
107
108
# File 'lib/rail_verdict/handoff.rb', line 104

def self.parse(text)
  return [nil, :handoff_too_large] if text.bytesize > MAX_DOCUMENT_BYTES
  document = begin JSON.parse(text) rescue return [nil, :handoff_malformed] end
  parse_document(document)
end

.parse_document(document) ⇒ Object



110
111
112
113
114
115
116
117
118
119
120
# File 'lib/rail_verdict/handoff.rb', line 110

def self.parse_document(document)
  return [nil, :handoff_malformed] unless document.is_a?(Hash)
  return [nil, :incompatible_handoff_version] unless document["schema_version"] == SCHEMA_VERSION
  errors = SchemaValidator.validate_handoff(document)
  return [nil, :handoff_schema_invalid] unless errors.empty?
  stored = document["handoff_id"]
  payload = document.reject { |k, _| k == "handoff_id" }
  expected = id_for(payload)
  return [nil, :handoff_integrity_failed] unless stored == expected
  [deep_freeze(document), nil]
end