Class: Clickwrap::Event

Inherits:
ApplicationRecord show all
Defined in:
lib/clickwrap/models/event.rb

Overview

One digest-bound evidence event with fixed, named post-write transitions.

A capture, a withdrawal, a correction, an expiry, a consumption, a disposition, a hold — each is a row here, linked to what it acts on. The ordinary update and destroy calls are refused. Finalization, pointer nullification, request-evidence linking, legal-hold state, and reviewed core disposition use explicit fixed write sets; lifecycle and disposition facts append linked events explaining what happened and why. Optional PostgreSQL hardening enforces those same sets below the model layer.

What an event proves is bounded and stated in the receipt: the server generated and accepted a particular presentation, an explicit action was submitted against it, and it committed together with whatever domain action it protected. It does not prove that a person read anything, understood anything, saw particular pixels, or that a party controlling both the application and the database could not have written the row.

Constant Summary collapse

FINALIZATION_COLUMNS =

Named write sets used by the opt-in PostgreSQL hardening migration. They document every post-INSERT path the gem itself needs, so the migration can refuse to install if its trigger policy ever drifts from runtime code.

%w[
  chain_scope chain_sequence previous_event_digest event_digest
  digest_algorithm canonical_schema_version
].freeze
POINTER_NULLIFICATION_COLUMNS =
%w[
  actor_type actor_id represented_party_type represented_party_id
  subject_type subject_id
].freeze
DISPOSITION_COLUMNS =
%w[
  actor_type actor_id actor_reference actor_snapshot
  represented_party_type represented_party_id represented_party_reference
  authority_source authority_role authority_verified_at authority_details
  tenant_key subject_type subject_id subject_key subject_fingerprint
  authentication_method authentication_context idempotency_key
  http_request_id http_route_name presentation_id presentation_manifest
  presentation_manifest_digest protected_outcome provider_receipt
  provider_verification reason core_event_disposed_at
  core_event_disposition_event_id
].freeze
MODEL_CALLBACK_MUTABLE_COLUMNS =
%w[
  core_event_disposed_at core_event_disposition_event_id on_legal_hold request_evidence_id
].freeze
MUTABLE_COLUMNS =
MODEL_CALLBACK_MUTABLE_COLUMNS
DATABASE_HARDENING_WRITE_SETS =
{
  "finalization" => FINALIZATION_COLUMNS,
  "pointer_nullification" => POINTER_NULLIFICATION_COLUMNS,
  "disposition" => DISPOSITION_COLUMNS,
  "legal_hold" => %w[on_legal_hold].freeze,
  "request_evidence_link" => %w[request_evidence_id].freeze
}.transform_values { |columns| columns.map(&:to_s).sort.freeze }.freeze

Instance Method Summary collapse

Instance Method Details

#attach_request_evidence!(record) ⇒ Object

Only the foreign key. The binding digest was written when the event was created, because it is part of the canonical body the event digest covers; setting it afterwards would leave every event with request evidence failing its own verification. request_evidence_id is safe to set here precisely because it is NOT in the canonical body — it is a pointer, not a fact about what was recorded.



527
528
529
# File 'lib/clickwrap/models/event.rb', line 527

def attach_request_evidence!(record)
  update_columns(request_evidence_id: record.id)
end

#authorization_was_consumed?Boolean

Returns:

  • (Boolean)


241
# File 'lib/clickwrap/models/event.rb', line 241

def authorization_was_consumed? = event_type == "consumption"

#canonical_bodyObject

The canonical body this event's digest covers. It deliberately excludes the mutable columns: whether a legal hold is currently in effect, and whether the optional annex has since been disposed of, are facts about today, not about what was recorded. Including them would make an ordinary retention run look like tampering.



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
# File 'lib/clickwrap/models/event.rb', line 270

def canonical_body
  {
    "schema" => canonical_schema_version,
    "event_id" => id,
    "event_type" => event_type,
    "policy" => { "key" => policy_key, "revision" => policy_revision&.revision_digest }.compact,
    "actor" => canonical_actor,
    "tenant" => tenant_key,
    "subject" => canonical_subject,
    "capture_channel" => capture_channel,
    "authentication_method" => authentication_method,
    "authentication_context" => authentication_context.presence,
    "recorded_at_by_server" => Receipt.format_time(recorded_at_by_server),
    "occurred_at" => Receipt.format_time(occurred_at),
    "idempotency_key" => idempotency_key,
    "http_request_id" => http_request_id,
    "http_route_name" => http_route_name,
    "acts" => statements.map(&:canonical_fragment),
    "documents" => documents.map(&:canonical_fragment),
    "presentation" => canonical_presentation,
    "protected_outcome" => protected_outcome.presence,
    "provider" => canonical_provider,
    "request_evidence" => canonical_request_evidence_binding,
    "predecessor_event_id" => predecessor_event_id,
    "root_event_id" => root_event_id,
    "reason" => reason,
    "retention" => {
      "class" => retention_class_key,
      "retain_core_event_until" => Receipt.format_time(retain_core_event_until),
      "rule" => retention_rule_name
    }.compact.presence,
    "chain" => {
      "scope" => chain_scope,
      "sequence" => chain_sequence,
      "previous_event_digest" => previous_event_digest
    }.compact.presence,
    "gem_version" => gem_version,
    "application_version" => application_version,
    "template_version" => template_version,
    "created_at" => Receipt.format_time(created_at)
  }.compact
end

#capture?Boolean

Returns:

  • (Boolean)


248
# File 'lib/clickwrap/models/event.rb', line 248

def capture? = event_type == "capture"

#compiled_policy_snapshotObject



261
# File 'lib/clickwrap/models/event.rb', line 261

def compiled_policy_snapshot = policy_revision&.compiled_snapshot

#compute_digestObject



313
314
315
# File 'lib/clickwrap/models/event.rb', line 313

def compute_digest
  Digest.digest_canonical(canonical_body, algorithm: digest_algorithm || "sha256")
end

Returns:

  • (Boolean)


239
# File 'lib/clickwrap/models/event.rb', line 239

def consent_was_granted? = capture? && statements.any? { |s| s.kind == "consent" && s.answered? }

Questions an after_event_is_committed hook actually asks. A host wiring up "stop processing when someone withdraws" should not have to know that the answer is a string comparison against an event-type vocabulary.

Returns:

  • (Boolean)


238
# File 'lib/clickwrap/models/event.rb', line 238

def consent_was_withdrawn? = event_type == "withdrawal"

#declaration_was_corrected?Boolean

Returns:

  • (Boolean)


240
# File 'lib/clickwrap/models/event.rb', line 240

def declaration_was_corrected? = event_type == "correction"

#digest_integrity_accounted_for?Boolean

Returns:

  • (Boolean)


342
343
344
# File 'lib/clickwrap/models/event.rb', line 342

def digest_integrity_accounted_for?
  digest_integrity_status != :unaccounted_mismatch
end

#digest_integrity_statusObject

A disposed core payload cannot be recomputed: the point of disposition is that those bytes are gone. Keep that state separate from a verifying digest. A valid, digest-bound disposition event can account for the mismatch, while an unexplained marker or altered row remains a failure.



330
331
332
333
334
335
336
337
338
339
340
# File 'lib/clickwrap/models/event.rb', line 330

def digest_integrity_status
  if disposed?
    return :documented_core_disposition if documented_core_disposition?

    return :unaccounted_mismatch
  end

  return :verified if digest_verified?

  :unaccounted_mismatch
end

#digest_verified?Boolean

Recomputes the digest and compares it with the one recorded at write time. A false here means the row's meaningful bytes changed since it was written; it does not, on its own, say who changed them or when.

Returns:

  • (Boolean)


320
321
322
323
324
# File 'lib/clickwrap/models/event.rb', line 320

def digest_verified?
  return false if event_digest.blank?

  Digest.secure_compare?(compute_digest, event_digest)
end

#dispose_core_payload!(disposition_event:, at: Clickwrap.now) ⇒ Object

Marks the core event as disposed of under a retention rule. The row stays: what disappears is the payload, and the disposition is itself recorded as a linked event, so an auditor sees a documented deletion rather than a gap.



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
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
# File 'lib/clickwrap/models/event.rb', line 451

def dispose_core_payload!(disposition_event:, at: Clickwrap.now)
  transaction do
    # Mark and link the documented disposition first. The PostgreSQL
    # hardening tier permits child DELETEs only while their parent carries
    # this marker. If any later deletion fails, this transaction rolls the
    # marker back as well, so callers can never observe a half-disposed row.
    update_columns(
      actor_type: nil,
      actor_id: nil,
      actor_reference: "",
      actor_snapshot: {},
      represented_party_type: nil,
      represented_party_id: nil,
      represented_party_reference: "",
      authority_source: nil,
      authority_role: nil,
      authority_verified_at: nil,
      authority_details: {},
      tenant_key: "",
      subject_type: nil,
      subject_id: nil,
      subject_key: "",
      subject_fingerprint: nil,
      authentication_method: nil,
      authentication_context: {},
      idempotency_key: nil,
      http_request_id: nil,
      http_route_name: nil,
      presentation_id: nil,
      presentation_manifest: nil,
      presentation_manifest_digest: nil,
      protected_outcome: nil,
      provider_receipt: nil,
      provider_verification: nil,
      reason: nil,
      core_event_disposed_at: at,
      core_event_disposition_event_id: disposition_event.id
    )

    # Calling `delete_all` through a `has_many` association asks Active
    # Record to null the foreign key. These evidence children have an
    # intentionally non-null foreign key, so issue real, scoped DELETEs.
    EventStatement.where(event_id: id).delete_all
    EventDocument.where(event_id: id).delete_all
    # A root and its later lifecycle events are independently retained
    # evidence payloads. Disposing the root must not erase a projection that
    # now points at a still-retained correction, renewal, withdrawal, or
    # other successor. Remove only projections for which this exact event is
    # current; a later successor removes its own projection when its own
    # schedule becomes due.
    StatementState.where(current_event_id: id).delete_all
  end
  self
end

#disposed?Boolean

Returns:

  • (Boolean)


252
# File 'lib/clickwrap/models/event.rb', line 252

def disposed? = core_event_disposed_at.present?

#documented_core_disposition?Boolean

Returns:

  • (Boolean)


506
507
508
509
510
511
512
513
514
515
# File 'lib/clickwrap/models/event.rb', line 506

def documented_core_disposition?
  return false unless disposed? && core_event_disposition_event_id.present?

  disposition = Event.find_by(id: core_event_disposition_event_id)
  facts = disposition&.protected_outcome.to_h["core_event_disposition"].to_h
  disposition_event_links_to_self?(disposition) && disposition.digest_verified? &&
    facts["event_id"] == id &&
    facts["original_event_digest"] == event_digest &&
    facts["disposed_at"] == Receipt.format_time(core_event_disposed_at)
end

#evidence_integrity_verified?Boolean

Returns:

  • (Boolean)


388
389
390
391
392
# File 'lib/clickwrap/models/event.rb', line 388

def evidence_integrity_verified?
  digest_verified? && %i[
    not_recorded verified disposed_with_documented_events
  ].include?(request_evidence_binding_status)
end

#evidence_was_disposed?Boolean

Returns:

  • (Boolean)


242
# File 'lib/clickwrap/models/event.rb', line 242

def evidence_was_disposed? = event_type == "disposition"

#exemption?Boolean

Returns:

  • (Boolean)


250
# File 'lib/clickwrap/models/event.rb', line 250

def exemption? = event_type == "exemption"

#external_actionObject



159
160
161
# File 'lib/clickwrap/models/event.rb', line 159

def external_action
  super if SchemaRequirements.available?(:external_actions)
end

#finalize_durable_commit!Object

Called only once durable commit is no longer capable of being rolled back. Public because DurableCommitCallback invokes it through Active Record's transaction-record protocol; it is not host application API.



434
435
436
437
438
439
# File 'lib/clickwrap/models/event.rb', line 434

def finalize_durable_commit!
  commit_pending_receipts
  run_after_commit_hook
  run_integrity_attestors
  self
end

#finalize_integrity!Object

Finalization is deliberately explicit and happens only after every child statement/document, protected outcome, registration binding, retention decision, and chain position exists. Computing this in before_create produced digests for a half-built event and made normal successful captures fail their own integrity check.

Raises:



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
# File 'lib/clickwrap/models/event.rb', line 399

def finalize_integrity!
  raise EventWriteFailed, "Event #{id} has not been persisted, so it cannot be finalized." unless persisted?
  if event_digest.present?
    raise EventWriteFailed,
          "Event #{id} was already finalized and cannot be finalized twice."
  end

  # Normally assigned by the before-create callback so chain contention is
  # the first database lock this event takes. Keep this idempotent call as a
  # defensive invariant for a host that deliberately bypassed callbacks
  # while constructing an internal event.
  assign_chain_position!
  self.event_digest = compute_digest

  update_columns(
    chain_scope: chain_scope,
    chain_sequence: chain_sequence,
    previous_event_digest: previous_event_digest,
    event_digest: event_digest,
    digest_algorithm: digest_algorithm,
    canonical_schema_version: canonical_schema_version
  )

  ChainHead.record!(chain_scope: chain_scope, event_id: id, event_digest: event_digest) if chain_scope.present?
  self
end

#held?Boolean

Returns:

  • (Boolean)


253
# File 'lib/clickwrap/models/event.rb', line 253

def held? = on_legal_hold?

#human_action?Boolean

Returns:

  • (Boolean)


251
# File 'lib/clickwrap/models/event.rb', line 251

def human_action? = Vocabulary.human_action_event_type?(event_type)

#imported?Boolean

Returns:

  • (Boolean)


249
# File 'lib/clickwrap/models/event.rb', line 249

def imported? = %w[imported_legacy external_receipt].include?(event_type)

#integrity_attestationsObject



147
148
149
150
151
# File 'lib/clickwrap/models/event.rb', line 147

def integrity_attestations
  return IntegrityAttestation.none unless SchemaRequirements.available?(:integrity)

  super
end

#invalidate_pending_receipts_after_rollback!Object



441
442
443
444
# File 'lib/clickwrap/models/event.rb', line 441

def invalidate_pending_receipts_after_rollback!
  Array(@pending_receipts).each(&:mark_rolled_back!)
  self
end


153
154
155
156
157
# File 'lib/clickwrap/models/event.rb', line 153

def legal_holds
  return LegalHold.none unless SchemaRequirements.available?(:retention_ops)

  super
end

#policyObject



259
# File 'lib/clickwrap/models/event.rb', line 259

def policy = Clickwrap.policies[policy_key]

#presentationObject

--- Associations whose tables are optional -------------------------------

clickwrap:install emits only the tables an installation can put a row in; the rest arrive with their own flag. Reading one of these associations on an installation that never created the table would read the schema of a table that deliberately does not exist — so each reader answers "there is nothing here", which is not a fallback but the exact truth: without the table, no row was ever written.

Guarded here rather than at each call site, because the call sites are everywhere and the answer is a property of this record.



139
140
141
# File 'lib/clickwrap/models/event.rb', line 139

def presentation
  super if SchemaRequirements.available?(:persisted_presentations)
end

#purpose_keysObject

The purposes this event affected, for a hook that needs to know which processing to stop.



246
# File 'lib/clickwrap/models/event.rb', line 246

def purpose_keys = statements.filter_map(&:purpose_key).uniq

#receiptObject



263
# File 'lib/clickwrap/models/event.rb', line 263

def receipt = Receipt.new(self)

#request_evidenceObject



143
144
145
# File 'lib/clickwrap/models/event.rb', line 143

def request_evidence
  super if SchemaRequirements.available?(:request_evidence)
end

#request_evidence_binding_statusObject

The optional annex has its own keyed binding. A permitted category disposition makes the original HMAC no longer recomputable; that state is accepted only when every deletion timestamp has a valid linked disposition event naming the category.



350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
# File 'lib/clickwrap/models/event.rb', line 350

def request_evidence_binding_status
  annex = request_evidence
  digests = request_evidence_category_binding_digests.to_h
  has_binding = digests.present? || request_evidence_key_id.present? ||
                request_evidence_digest_algorithm.present?

  return :not_recorded if annex.nil? && !has_binding
  return :missing_annex_or_binding if annex.nil? || !has_binding
  return :missing_annex_or_binding unless digests.keys.sort == RequestEvidence::CATEGORIES.map(&:to_s).sort

  return :binding_key_unavailable unless annex.binding_key_available?(request_evidence_key_id)

  disposed = false
  RequestEvidence::CATEGORIES.each do |category|
    if annex.deleted_for?(category)
      disposed = true
      return :undocumented_disposition unless request_evidence_disposition_documented?(annex, category)

      next
    end

    verified = annex.category_binding_digest_verified?(
      category: category,
      digest: digests[category.to_s],
      algorithm: request_evidence_digest_algorithm,
      key_id: request_evidence_key_id
    )
    return :digest_mismatch unless verified
  end

  disposed ? :disposed_with_documented_events : :verified
rescue ::ActiveRecord::Encryption::Errors::Base
  # Losing or rotating away an encryption key is different from a digest
  # mismatch. The annex cannot currently be read, so verification reports
  # that limited fact instead of crashing or calling the bytes modified.
  :annex_unreadable
end


517
518
519
# File 'lib/clickwrap/models/event.rb', line 517

def set_legal_hold!(held)
  update_columns(on_legal_hold: held)
end

#statement(statement_key) ⇒ Object



255
256
257
# File 'lib/clickwrap/models/event.rb', line 255

def statement(statement_key)
  statements.find { |candidate| candidate.statement_key == statement_key.to_s }
end

#to_sObject



531
# File 'lib/clickwrap/models/event.rb', line 531

def to_s = "#{event_type} #{policy_key} #{id}"

#track_pending_receipt(pending_receipt) ⇒ Object



426
427
428
429
# File 'lib/clickwrap/models/event.rb', line 426

def track_pending_receipt(pending_receipt)
  (@pending_receipts ||= []) << pending_receipt
  pending_receipt
end