Module: Clickwrap::ProtectedOutcome
- Defined in:
- lib/clickwrap/protected_outcome.rb
Overview
Builds the exact result a protected database action records in its evidence event, and re-derives its digest so the recorded claim cannot be edited after the fact. What the digest binds is what the recorder returned — it does not independently verify the recorder told the truth. Hosts provide ordinary canonical facts; Clickwrap owns the stable reference and the digest over the complete claim.
Clickwrap.protected_outcome(
action: :submitted,
record: withdrawal,
state: withdrawal.status,
facts: {
amount_in_cents: withdrawal.amount_cents,
currency: withdrawal.currency,
destination_reference: withdrawal.destination_reference
}
)
The fingerprint covers the action, record reference, state, and every fact. It detects later changes to that snapshot; it is not a signature, identity proof, trusted timestamp, or substitute for the linked domain record.
Constant Summary collapse
- REQUIRED_KEYS =
%w[action facts fingerprint reference].freeze
- OPTIONAL_KEYS =
%w[state].freeze
- PERMITTED_KEYS =
(REQUIRED_KEYS + OPTIONAL_KEYS).freeze
Class Method Summary collapse
Class Method Details
.build(action:, record:, facts:, state: nil) ⇒ Object
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
# File 'lib/clickwrap/protected_outcome.rb', line 31 def build(action:, record:, facts:, state: nil) action = required_string(action, "action") if record.respond_to?(:persisted?) && !record.persisted? raise ArgumentError, "A protected outcome needs a persisted `record:` with a stable reference. " \ "Save the result inside the protected-action block and return it." end reference = Reference.record(record) if reference.blank? raise ArgumentError, "A protected outcome needs a persisted `record:` with a stable reference." end normalized_facts = normalize_facts(facts) claim = { "action" => action, "reference" => reference, "facts" => normalized_facts } claim["state"] = required_string(state, "state") unless state.nil? claim["fingerprint"] = fingerprint_for(claim) claim.freeze end |
.validate!(outcome) ⇒ Object
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 |
# File 'lib/clickwrap/protected_outcome.rb', line 55 def validate!(outcome) unless outcome.is_a?(Hash) raise DefinitionError, "A protected-outcome recorder must return `Clickwrap.protected_outcome(...)`, " \ "which returns a Hash, but it returned #{outcome.class}." end normalized = outcome.deep_stringify_keys unknown = normalized.keys - PERMITTED_KEYS missing = REQUIRED_KEYS - normalized.keys if unknown.any? || missing.any? details = [] details << "missing #{missing.join(", ")}" if missing.any? details << "unknown #{unknown.join(", ")}" if unknown.any? raise DefinitionError, "The protected outcome has #{details.join("; ")}. Build it with " \ "`Clickwrap.protected_outcome(action:, record:, facts:, state: nil)` so its " \ "meaning and fingerprint are complete." end required_string(normalized["action"], "action") required_string(normalized["reference"], "reference") required_string(normalized["state"], "state") if normalized.key?("state") normalized["facts"] = normalize_facts(normalized["facts"]) expected = fingerprint_for(normalized.except("fingerprint")) unless Digest.secure_compare?(normalized["fingerprint"], expected) raise DefinitionError, "The protected outcome fingerprint does not match its action, record reference, " \ "state, and facts. Build the outcome after the protected action finishes by " \ "calling `Clickwrap.protected_outcome(...)`." end normalized rescue CanonicalJson::SerializationError => error raise DefinitionError, "Protected-outcome facts must be canonical JSON values: #{error.}" end |