Class: Clickwrap::Receipt

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

Overview

The answer to "show me exactly what the application recorded."

One canonical JSON body per event, plus a human-readable projection of the same facts. The canonical form is what gets digested and independently verified, so it is built from the stored evidence alone: no Ruby object serialization, no current policy source, no database column order, no locale-dependent number formatting. A verifier written years from now in another language must be able to reproduce these bytes from the data.

Raw IP address, browser user-agent, and IP-geolocation values are NOT in the canonical body. They live in a separately encrypted annex with its own authorization, retention, hold, and disposition state, and the body carries only a keyed digest binding the two. That boundary is what lets the core event stay immutable and verifiable when a permitted retention process later removes the annex — deletion changes what a receipt can show, not what it says happened.

Constant Summary collapse

SCHEMA =
"clickwrap.receipt.v1"
TIME_FORMAT =

The Clickwrap profile for timestamps: UTC, exactly six fractional digits, Z suffix. Fixed width so two verifiers never disagree about whether a trailing zero was significant.

"%Y-%m-%dT%H:%M:%S.%6NZ"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(event) ⇒ Receipt

Returns a new instance of Receipt.



32
33
34
# File 'lib/clickwrap/receipt.rb', line 32

def initialize(event)
  @event = event
end

Instance Attribute Details

#eventObject (readonly)

Returns the value of attribute event.



30
31
32
# File 'lib/clickwrap/receipt.rb', line 30

def event
  @event
end

Class Method Details

.export(receipt, requested_by: nil, because: nil, include_ip_address: false, include_browser_user_agent: false, include_ip_geolocation: false, access_channel: "export") ⇒ Object

An export names each sensitive field it includes. There is deliberately no include_sensitive_context: true: one flag that turns on three different categories of personal data is exactly the kind of option that makes an operator's intent unreviewable.



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
# File 'lib/clickwrap/receipt.rb', line 63

def export(receipt, requested_by: nil, because: nil,
           include_ip_address: false, include_browser_user_agent: false,
           include_ip_geolocation: false, access_channel: "export")
  receipt = find(receipt) if receipt.is_a?(String)
  requested = { ip_address: include_ip_address,
                browser_user_agent: include_browser_user_agent,
                ip_geolocation: include_ip_geolocation }

  authorize_export!(receipt, requested, requested_by, because)
  # A redacted receipt contains no annex value and therefore performs no
  # privileged read. It needs neither an access row nor a transaction of
  # its own; making ordinary exports depend on commit context would make
  # harmless rendering fail inside otherwise unrelated host work.
  return receipt.to_h unless requested.value?(true)

  refuse_export_inside_an_outer_transaction!

  exported = nil
  Event.transaction(requires_new: true) do
    event = Event.lock.find(receipt.event.id)
    current_receipt = new(event)
    included = requested.transform_keys(&:to_s)
    requested_by_reference = Reference.actor(requested_by)

    ReceiptAccess.record!(
      event: event,
      requested_by: requested_by,
      because: because,
      included_fields: included,
      access_channel: access_channel
    )

    Lifecycle.append_lifecycle_event!(
      event: event,
      event_type: "receipt_access",
      reason: because.presence || "Exported a redacted receipt",
      actor: requested_by,
      extra: {
        protected_outcome: {
          "receipt_access" => {
            "requested_by_reference" => requested_by_reference,
            "included_fields" => included,
            "access_channel" => access_channel.to_s
          }.compact
        }
      }
    )

    exported = current_receipt.send(
      :to_h_with_revealed_request_evidence,
      requested.select { |_, wanted| wanted }.keys
    )
  end

  exported
end

.find(event_id) ⇒ Object

Raises:



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/clickwrap/receipt.rb', line 37

def find(event_id)
  # The annex is eager-loaded only where it can exist. An installation
  # that records no request evidence never created that table, and
  # `includes` would read its schema before deciding there is nothing in
  # it. The receipt still reports every category by state — the states
  # just all come out `not_configured`, which is the true answer.
  associations = %i[statements documents policy_revision]
  associations << :request_evidence if SchemaRequirements.available?(:request_evidence)

  event = Event.includes(*associations).find_by(id: event_id)

  raise ReceiptInvalid, "No Clickwrap event with id #{event_id.inspect}" unless event

  new(event)
end

.format_time(time) ⇒ Object



53
54
55
56
57
# File 'lib/clickwrap/receipt.rb', line 53

def format_time(time)
  return nil if time.nil?

  time.utc.strftime(TIME_FORMAT)
end

.verify(canonical_json, documents: {}) ⇒ Object

Verifies a receipt with no host application, no database, and no policy source — the standalone path the CLI uses.



122
123
124
# File 'lib/clickwrap/receipt.rb', line 122

def verify(canonical_json, documents: {})
  ReceiptVerifier.verify(canonical_json, documents: documents)
end

Instance Method Details

#actorObject



163
# File 'lib/clickwrap/receipt.rb', line 163

def actor = event.actor

#actor_referenceObject



162
# File 'lib/clickwrap/receipt.rb', line 162

def actor_reference = event.actor_reference

#as_jsonObject



212
# File 'lib/clickwrap/receipt.rb', line 212

def as_json(*) = to_h

#committed?Boolean

Returns:

  • (Boolean)


165
# File 'lib/clickwrap/receipt.rb', line 165

def committed? = true

#digest_verified?Boolean

Returns:

  • (Boolean)


258
# File 'lib/clickwrap/receipt.rb', line 258

def digest_verified? = event.digest_verified?

#documentsObject



168
# File 'lib/clickwrap/receipt.rb', line 168

def documents = event.documents

#event_idObject

--- Identity -------------------------------------------------------------



159
# File 'lib/clickwrap/receipt.rb', line 159

def event_id = event.id

#held?Boolean

Returns:

  • (Boolean)


171
# File 'lib/clickwrap/receipt.rb', line 171

def held? = event.on_legal_hold?

#inspectObject



305
# File 'lib/clickwrap/receipt.rb', line 305

def inspect = "#<Clickwrap::Receipt #{event_id} #{policy_key}>"


303
# File 'lib/clickwrap/receipt.rb', line 303

def legal_holds = LegalHold.for_event(event.id)

--- Legal holds ----------------------------------------------------------



262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/clickwrap/receipt.rb', line 262

def place_on_legal_hold!(because:, placed_by:, review_at:)
  SchemaRequirements.require!(:retention_ops)

  hold = nil

  ::ActiveRecord::Base.transaction do
    locked_event = Event.lock.find(event.id)
    hold = LegalHold.create!(
      hold_scope: "event",
      event_id: event.id,
      reason: because,
      placed_by_reference: reference_for(placed_by),
      placed_at: Clickwrap.now,
      review_at: review_at,
      created_at: Clickwrap.now
    )

    locked_event.set_legal_hold!(true)
    Lifecycle.append_lifecycle_event!(event: locked_event, event_type: "legal_hold_placed",
                                      reason: because, actor: placed_by)
  end

  hold
end

#policy_keyObject



160
# File 'lib/clickwrap/receipt.rb', line 160

def policy_key = event.policy_key

#recorded_at_by_serverObject



161
# File 'lib/clickwrap/receipt.rb', line 161

def recorded_at_by_server = event.recorded_at_by_server


287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/clickwrap/receipt.rb', line 287

def release_legal_hold!(because:, released_by:)
  ::ActiveRecord::Base.transaction do
    locked_event = Event.lock.find(event.id)
    holds = LegalHold.lock.for_event(event.id).in_effect.to_a
    return nil if holds.empty?

    holds.each do |hold|
      hold.release!(because: because, released_by: released_by)
    end

    locked_event.set_legal_hold!(LegalHold.for_event(event.id).in_effect.exists?)
    Lifecycle.append_lifecycle_event!(event: locked_event, event_type: "legal_hold_released",
                                      reason: because, actor: released_by)
  end
end

#request_evidenceObject



169
# File 'lib/clickwrap/receipt.rb', line 169

def request_evidence = event.request_evidence

#statementsObject



167
# File 'lib/clickwrap/receipt.rb', line 167

def statements = event.statements

#subjectObject



164
# File 'lib/clickwrap/receipt.rb', line 164

def subject = event.subject

#to_canonical_jsonObject



208
# File 'lib/clickwrap/receipt.rb', line 208

def to_canonical_json = CanonicalJson.generate(to_h)

#to_hObject

The canonical, digestible body.

integrity.receipt_digest covers this exact body with only the self-referential integrity.receipt_digest field removed — the digest cannot cover itself, and the exclusion has to be one a verifier in another language can reproduce without guessing. It is a different value from integrity.event_digest, which the application computed over the event's own canonical body when the event was written. Both are reported, because they answer different questions: one says this file has not been edited, the other says the row it describes has not been.

An export that reveals request evidence is a different document from a redacted one, so it carries a different receipt digest. That is correct: each file verifies as the file it actually is.



189
190
191
# File 'lib/clickwrap/receipt.rb', line 189

def to_h
  build_receipt_body(revealed_request_evidence: [])
end

#to_html(view_context: nil) ⇒ Object

The human-readable projection. Same facts, rendered — never a different set of facts, and never a stronger claim than the canonical body makes.



216
# File 'lib/clickwrap/receipt.rb', line 216

def to_html(view_context: nil) = ReceiptHtml.new(self, view_context: view_context).render

#to_jsonObject



210
# File 'lib/clickwrap/receipt.rb', line 210

def to_json(*) = to_canonical_json

#to_pdfObject

PDF is optional rendering of the receipt, never the source of truth. The gem ships no PDF dependency; a host configures a renderer if it wants one.

Raises:



245
246
247
248
249
250
# File 'lib/clickwrap/receipt.rb', line 245

def to_pdf(*)
  raise ConfigurationError,
        "Clickwrap does not render PDFs itself — a PDF library is not a dependency of an " \
        "evidence gem, and a PDF is a rendering of the receipt rather than the record. " \
        "Render `to_html` with your own PDF pipeline if you need one."
end

#to_sObject



307
# File 'lib/clickwrap/receipt.rb', line 307

def to_s = event_id

#verifyObject

--- Verification ---------------------------------------------------------



254
255
256
# File 'lib/clickwrap/receipt.rb', line 254

def verify
  Verification.verify(event.id)
end