Module: RailVerdict::Reuse

Defined in:
lib/rail_verdict/reuse.rb

Defined Under Namespace

Classes: Result

Constant Summary collapse

REUSABLE =
"REUSABLE"
VERIFICATION_REQUIRED =
"VERIFICATION_REQUIRED"
INVALID =
"INVALID"
UNAVAILABLE =
"UNAVAILABLE"

Class Method Summary collapse

Class Method Details

.evaluate(handoff_document:, current_repository_state:, current_environment:, current_contract: {}) ⇒ Object

One canonical evaluator — CLI/MCP/CI/Repair must delegate here.



20
21
22
23
24
25
26
27
28
29
30
31
32
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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/rail_verdict/reuse.rb', line 20

def self.evaluate(handoff_document:, current_repository_state:, current_environment:, current_contract: {})
  # 1. Validate handoff structurally
  unless handoff_document.is_a?(Hash) && handoff_document["handoff_id"]
    return Result.new(decision: INVALID, reasons: ["handoff_invalid"], handoff_valid: false, receipt_fresh: false)
  end
  stored = handoff_document["handoff_id"]
  payload = handoff_document.reject { |k, _| k == "handoff_id" }
  expected = Handoff.id_for(payload)
  unless stored == expected
    return Result.new(decision: INVALID, reasons: ["handoff_tampered"], handoff_valid: false, receipt_fresh: false)
  end
  errors = SchemaValidator.validate_handoff(handoff_document)
  unless errors.empty?
    return Result.new(decision: INVALID, reasons: ["handoff_invalid"], handoff_valid: false, receipt_fresh: false)
  end

  # Size bound already enforced in Handoff.build/parse, but check
  if JSON.generate(handoff_document).bytesize > Handoff::MAX_DOCUMENT_BYTES
    return Result.new(decision: INVALID, reasons: ["handoff_too_large"], handoff_valid: false, receipt_fresh: false)
  end

  receipt = handoff_document["receipt"]
  unless receipt && receipt["receipt_id"]
    return Result.new(decision: INVALID, reasons: ["handoff_invalid"], handoff_valid: false, receipt_fresh: false)
  end

  # 2. TOCTOU guard: observe identity_before, then evaluate, then re-observe is caller's responsibility
  # Here we check provided current state/env vs receipt binding
  # Freshness via Receipt.validate_freshness equivalent — compare repository_state + environment digests
  freshness = evaluate_freshness(receipt, current_repository_state, current_environment)
  unless freshness[:fresh]
    return Result.new(decision: VERIFICATION_REQUIRED, reasons: freshness[:reasons], handoff_valid: true, receipt_fresh: false)
  end

  # 3. Completeness / required evidence
  required = Array(current_contract["required_analyzers"] || current_contract[:required_analyzers]).map(&:to_s)
  evidence_analyzers = Array(handoff_document.dig("evidence_set", "analyzer_results")).map { |ar| ar["analyzer"].to_s }

  missing = required - evidence_analyzers
  unless missing.empty?
    return Result.new(decision: VERIFICATION_REQUIRED, reasons: ["required_analyzer_missing:#{missing.join(',')}"], handoff_valid: true, receipt_fresh: true)
  end

  # 4. Incomplete evidence cannot be reused as complete
  handoff_document.dig("evidence_set", "analyzer_results")&.each do |ar|
    if !%w[succeeded success].include?(ar["execution_status"])
      return Result.new(decision: VERIFICATION_REQUIRED, reasons: ["evidence_incomplete:#{ar['analyzer']}"], handoff_valid: true, receipt_fresh: true)
    end
  end

  # 5. Per-analyzer predicates (whole-set: all must be reusable)
  # RuboCop: reusable if analyzer_versions match current environment for rubocop
  # RSpec/Minitest/SimpleCov: never reusable (fail-closed) — forces VERIFICATION_REQUIRED
  # bundler-audit: requires advisory DB equivalence — unobservable → VERIFICATION_REQUIRED
  non_reusable = []
  required.each do |analyzer|
    case analyzer
    when "rspec", "minitest", "simplecov"
      non_reusable << "analyzer_not_reusable:#{analyzer}"
    when "bundler_audit", "bundler-audit"
      # If advisory DB revision not observable or not proven equal, fail-closed
      prov = handoff_document["evidence_provenance"]
      current_db = current_contract["advisory_db_revision"] || current_contract[:advisory_db_revision]
      handoff_db = prov && (prov["advisory_db_revision"] || prov[:advisory_db_revision])
      if handoff_db.nil? || current_db.nil? || handoff_db.to_s != current_db.to_s
        non_reusable << "advisory_database_changed"
      end
    when "rubocop"
      # Check analyzer version equality
      receipt_versions = receipt.dig("environment", "analyzer_versions") || {}
      current_versions = current_environment.is_a?(Hash) ? (current_environment["analyzer_versions"] || current_environment[:analyzer_versions] || {}) : {}
      # Also check handoff provenance
      if receipt_versions["rubocop"].to_s != current_versions["rubocop"].to_s && !current_versions["rubocop"].nil?
        non_reusable << "analyzer_version_changed:rubocop"
      end
    end
  end

  unless non_reusable.empty?
    return Result.new(decision: VERIFICATION_REQUIRED, reasons: non_reusable.uniq, handoff_valid: true, receipt_fresh: true)
  end

  # Scope / policy checks (current_contract may include base, mode)
  if current_contract["verification_mode"] || current_contract[:verification_mode]
    handoff_mode = handoff_document["source_scope"] && handoff_document["source_scope"]["verification_mode"]
    current_mode = (current_contract["verification_mode"] || current_contract[:verification_mode]).to_s
    if handoff_mode && handoff_mode != current_mode
      return Result.new(decision: VERIFICATION_REQUIRED, reasons: ["scope_changed"], handoff_valid: true, receipt_fresh: true)
    end
  end
  if current_contract["changed_base"] || current_contract[:changed_base]
    handoff_base = handoff_document.dig("source_scope", "changed_base")
    current_base = (current_contract["changed_base"] || current_contract[:changed_base]).to_s
    if handoff_base && handoff_base != current_base
      return Result.new(decision: VERIFICATION_REQUIRED, reasons: ["base_changed"], handoff_valid: true, receipt_fresh: true)
    end
  end

  Result.new(decision: REUSABLE, reasons: [], handoff_valid: true, receipt_fresh: true)
end