clickwrap 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +650 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +245 -0
  7. data/app/controllers/clickwrap/application_controller.rb +79 -0
  8. data/app/controllers/clickwrap/captures_controller.rb +145 -0
  9. data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
  10. data/app/controllers/clickwrap/receipts_controller.rb +115 -0
  11. data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
  12. data/app/helpers/clickwrap/engine_helper.rb +97 -0
  13. data/app/views/clickwrap/captures/show.html.erb +34 -0
  14. data/app/views/clickwrap/receipts/index.html.erb +38 -0
  15. data/app/views/clickwrap/receipts/show.html.erb +91 -0
  16. data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
  17. data/app/views/clickwrap/shared/_fields.html.erb +100 -0
  18. data/app/views/clickwrap/shared/_statement.html.erb +105 -0
  19. data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
  20. data/config/locales/en.yml +160 -0
  21. data/config/locales/es.yml +154 -0
  22. data/config/routes.rb +41 -0
  23. data/exe/clickwrap +374 -0
  24. data/guides/README.md +30 -0
  25. data/guides/accessibility.md +249 -0
  26. data/guides/consent-and-lifecycle.md +299 -0
  27. data/guides/integrating.md +610 -0
  28. data/guides/integrity.md +212 -0
  29. data/guides/migrating.md +335 -0
  30. data/guides/naming.md +320 -0
  31. data/guides/organizations.md +320 -0
  32. data/guides/receipts-and-verification.md +415 -0
  33. data/guides/request-evidence.md +512 -0
  34. data/guides/retention-and-legal-holds.md +438 -0
  35. data/lib/clickwrap/actor_proxy.rb +147 -0
  36. data/lib/clickwrap/anonymous_actor.rb +47 -0
  37. data/lib/clickwrap/authority.rb +174 -0
  38. data/lib/clickwrap/canonical_json.rb +216 -0
  39. data/lib/clickwrap/capture/event_builder.rb +220 -0
  40. data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
  41. data/lib/clickwrap/capture.rb +650 -0
  42. data/lib/clickwrap/configuration.rb +1129 -0
  43. data/lib/clickwrap/controller_helpers.rb +758 -0
  44. data/lib/clickwrap/current_state.rb +282 -0
  45. data/lib/clickwrap/digest.rb +125 -0
  46. data/lib/clickwrap/doctor.rb +418 -0
  47. data/lib/clickwrap/document_definition.rb +255 -0
  48. data/lib/clickwrap/document_renderer.rb +83 -0
  49. data/lib/clickwrap/document_renderers/markdown.rb +175 -0
  50. data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
  51. data/lib/clickwrap/dsl/policy_builder.rb +462 -0
  52. data/lib/clickwrap/dsl/retention_builder.rb +89 -0
  53. data/lib/clickwrap/durable_commit_callback.rb +37 -0
  54. data/lib/clickwrap/engine.rb +187 -0
  55. data/lib/clickwrap/errors.rb +181 -0
  56. data/lib/clickwrap/form_builder_extensions.rb +341 -0
  57. data/lib/clickwrap/front_matter.rb +67 -0
  58. data/lib/clickwrap/identifier.rb +112 -0
  59. data/lib/clickwrap/import/external_receipt.rb +241 -0
  60. data/lib/clickwrap/import/fine_print.rb +290 -0
  61. data/lib/clickwrap/import/legacy.rb +450 -0
  62. data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
  63. data/lib/clickwrap/integrity/anchor.rb +130 -0
  64. data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
  65. data/lib/clickwrap/integrity/attestor.rb +221 -0
  66. data/lib/clickwrap/integrity/chain.rb +313 -0
  67. data/lib/clickwrap/integrity/timestamp.rb +143 -0
  68. data/lib/clickwrap/ip_geolocation/location.rb +112 -0
  69. data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
  70. data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
  71. data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
  72. data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
  73. data/lib/clickwrap/ip_geolocation.rb +16 -0
  74. data/lib/clickwrap/lifecycle.rb +534 -0
  75. data/lib/clickwrap/linter.rb +382 -0
  76. data/lib/clickwrap/localized_text.rb +101 -0
  77. data/lib/clickwrap/macros.rb +203 -0
  78. data/lib/clickwrap/models/application_record.rb +20 -0
  79. data/lib/clickwrap/models/chain_head.rb +79 -0
  80. data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
  81. data/lib/clickwrap/models/disposition_plan.rb +208 -0
  82. data/lib/clickwrap/models/document.rb +46 -0
  83. data/lib/clickwrap/models/document_version.rb +163 -0
  84. data/lib/clickwrap/models/event.rb +743 -0
  85. data/lib/clickwrap/models/event_document.rb +79 -0
  86. data/lib/clickwrap/models/event_statement.rb +92 -0
  87. data/lib/clickwrap/models/external_action.rb +150 -0
  88. data/lib/clickwrap/models/integrity_attestation.rb +90 -0
  89. data/lib/clickwrap/models/legal_hold.rb +81 -0
  90. data/lib/clickwrap/models/policy_revision.rb +115 -0
  91. data/lib/clickwrap/models/presentation.rb +59 -0
  92. data/lib/clickwrap/models/receipt_access.rb +53 -0
  93. data/lib/clickwrap/models/recording_sequence.rb +21 -0
  94. data/lib/clickwrap/models/request_evidence.rb +378 -0
  95. data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
  96. data/lib/clickwrap/models/statement_state.rb +130 -0
  97. data/lib/clickwrap/pending_receipt.rb +177 -0
  98. data/lib/clickwrap/policy.rb +283 -0
  99. data/lib/clickwrap/presentation_manifest.rb +210 -0
  100. data/lib/clickwrap/presenter.rb +729 -0
  101. data/lib/clickwrap/privacy.rb +419 -0
  102. data/lib/clickwrap/protected_outcome.rb +120 -0
  103. data/lib/clickwrap/receipt.rb +606 -0
  104. data/lib/clickwrap/receipt_html.rb +235 -0
  105. data/lib/clickwrap/receipt_verifier.rb +978 -0
  106. data/lib/clickwrap/reference.rb +44 -0
  107. data/lib/clickwrap/registration.rb +236 -0
  108. data/lib/clickwrap/registry.rb +54 -0
  109. data/lib/clickwrap/remediation_token.rb +155 -0
  110. data/lib/clickwrap/request_evidence_extractor.rb +590 -0
  111. data/lib/clickwrap/request_evidence_policy.rb +261 -0
  112. data/lib/clickwrap/retention/applier.rb +231 -0
  113. data/lib/clickwrap/retention/disposition.rb +221 -0
  114. data/lib/clickwrap/retention/planner.rb +502 -0
  115. data/lib/clickwrap/retention_class.rb +97 -0
  116. data/lib/clickwrap/reviewed_text.rb +28 -0
  117. data/lib/clickwrap/schema_requirements.rb +196 -0
  118. data/lib/clickwrap/services/authorize_external_action.rb +149 -0
  119. data/lib/clickwrap/services/load_policies.rb +69 -0
  120. data/lib/clickwrap/services/publish_documents.rb +251 -0
  121. data/lib/clickwrap/services/validate_policy_references.rb +166 -0
  122. data/lib/clickwrap/statement.rb +248 -0
  123. data/lib/clickwrap/subject_fingerprint.rb +28 -0
  124. data/lib/clickwrap/submission.rb +169 -0
  125. data/lib/clickwrap/system_actor.rb +31 -0
  126. data/lib/clickwrap/test_helpers.rb +676 -0
  127. data/lib/clickwrap/testing.rb +211 -0
  128. data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
  129. data/lib/clickwrap/verification.rb +504 -0
  130. data/lib/clickwrap/version.rb +12 -1
  131. data/lib/clickwrap/view_helpers.rb +190 -0
  132. data/lib/clickwrap/vocabulary.rb +294 -0
  133. data/lib/clickwrap.rb +497 -7
  134. data/lib/generators/clickwrap/document_generator.rb +164 -0
  135. data/lib/generators/clickwrap/hardening_generator.rb +177 -0
  136. data/lib/generators/clickwrap/install_generator.rb +1287 -0
  137. data/lib/generators/clickwrap/link_generator.rb +56 -0
  138. data/lib/generators/clickwrap/policy_generator.rb +118 -0
  139. data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
  140. data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
  141. data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
  142. data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
  143. data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
  144. data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
  145. data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
  146. data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
  147. data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
  148. data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
  149. data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
  150. data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
  151. data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
  152. data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
  153. data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
  154. data/lib/generators/clickwrap/views_generator.rb +101 -0
  155. data/lib/tasks/clickwrap.rake +569 -0
  156. metadata +311 -16
@@ -0,0 +1,743 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # One digest-bound evidence event with fixed, named post-write transitions.
5
+ #
6
+ # A capture, a withdrawal, a correction, an expiry, a consumption, a
7
+ # disposition, a hold — each is a row here, linked to what it acts on. The
8
+ # ordinary `update` and `destroy` calls are refused. Finalization, pointer
9
+ # nullification, request-evidence linking, legal-hold state, and reviewed core
10
+ # disposition use explicit fixed write sets; lifecycle and disposition facts
11
+ # append linked events explaining what happened and why. Optional PostgreSQL
12
+ # hardening enforces those same sets below the model layer.
13
+ #
14
+ # What an event proves is bounded and stated in the receipt: the server
15
+ # generated and accepted a particular presentation, an explicit action was
16
+ # submitted against it, and it committed together with whatever domain action
17
+ # it protected. It does not prove that a person read anything, understood
18
+ # anything, saw particular pixels, or that a party controlling both the
19
+ # application and the database could not have written the row.
20
+ class Event < ApplicationRecord
21
+ self.table_name = "clickwrap_events"
22
+ self.primary_key = "id"
23
+
24
+ # Ordinary mutation is not part of this model's contract. Rails' own
25
+ # `touch`, counter caches, and `update_column` would all bypass the
26
+ # callbacks below, so the columns those would reach simply do not exist:
27
+ # there is no `updated_at`, no counter cache, and no lock version.
28
+ self.record_timestamps = false
29
+
30
+ # MySQL does not permit a default on a native JSON column. Keep the
31
+ # portable NOT NULL guarantee by assigning the empty binding manifest in
32
+ # the model for lifecycle/import events that have no request annex.
33
+ attribute :request_evidence_category_binding_digests, default: -> { {} }
34
+
35
+ # Named write sets used by the opt-in PostgreSQL hardening migration. They
36
+ # document every post-INSERT path the gem itself needs, so the migration can
37
+ # refuse to install if its trigger policy ever drifts from runtime code.
38
+ FINALIZATION_COLUMNS = %w[
39
+ chain_scope chain_sequence previous_event_digest event_digest
40
+ digest_algorithm canonical_schema_version
41
+ ].freeze
42
+ POINTER_NULLIFICATION_COLUMNS = %w[
43
+ actor_type actor_id represented_party_type represented_party_id
44
+ subject_type subject_id
45
+ ].freeze
46
+ DISPOSITION_COLUMNS = %w[
47
+ actor_type actor_id actor_reference actor_snapshot
48
+ represented_party_type represented_party_id represented_party_reference
49
+ authority_source authority_role authority_verified_at authority_details
50
+ tenant_key subject_type subject_id subject_key subject_fingerprint
51
+ authentication_method authentication_context idempotency_key
52
+ http_request_id http_route_name presentation_id presentation_manifest
53
+ presentation_manifest_digest protected_outcome provider_receipt
54
+ provider_verification reason core_event_disposed_at
55
+ core_event_disposition_event_id
56
+ ].freeze
57
+ MODEL_CALLBACK_MUTABLE_COLUMNS = %w[
58
+ core_event_disposed_at core_event_disposition_event_id on_legal_hold request_evidence_id
59
+ ].freeze
60
+ MUTABLE_COLUMNS = MODEL_CALLBACK_MUTABLE_COLUMNS
61
+ DATABASE_HARDENING_WRITE_SETS = {
62
+ "finalization" => FINALIZATION_COLUMNS,
63
+ "pointer_nullification" => POINTER_NULLIFICATION_COLUMNS,
64
+ "disposition" => DISPOSITION_COLUMNS,
65
+ "legal_hold" => %w[on_legal_hold].freeze,
66
+ "request_evidence_link" => %w[request_evidence_id].freeze
67
+ }.transform_values { |columns| columns.map(&:to_s).sort.freeze }.freeze
68
+
69
+ belongs_to :policy_revision,
70
+ class_name: "Clickwrap::PolicyRevision",
71
+ optional: true,
72
+ inverse_of: :events
73
+
74
+ belongs_to :presentation,
75
+ class_name: "Clickwrap::Presentation",
76
+ optional: true,
77
+ inverse_of: :events
78
+
79
+ belongs_to :actor, polymorphic: true, optional: true
80
+ belongs_to :subject, polymorphic: true, optional: true
81
+ belongs_to :represented_party, polymorphic: true, optional: true
82
+
83
+ has_many :statements,
84
+ -> { order(:ordinal) },
85
+ class_name: "Clickwrap::EventStatement",
86
+ foreign_key: :event_id,
87
+ inverse_of: :event,
88
+ dependent: :restrict_with_error
89
+
90
+ has_many :documents,
91
+ -> { order(:ordinal) },
92
+ class_name: "Clickwrap::EventDocument",
93
+ foreign_key: :event_id,
94
+ inverse_of: :event,
95
+ dependent: :restrict_with_error
96
+
97
+ has_one :request_evidence,
98
+ class_name: "Clickwrap::RequestEvidence",
99
+ foreign_key: :event_id,
100
+ inverse_of: :event,
101
+ dependent: :restrict_with_error
102
+
103
+ has_many :legal_holds,
104
+ class_name: "Clickwrap::LegalHold",
105
+ foreign_key: :event_id,
106
+ inverse_of: :event,
107
+ dependent: :restrict_with_error
108
+
109
+ has_many :accesses,
110
+ class_name: "Clickwrap::ReceiptAccess",
111
+ foreign_key: :event_id,
112
+ inverse_of: :event,
113
+ dependent: :restrict_with_error
114
+
115
+ has_many :integrity_attestations,
116
+ -> { order(:attempted_at, :id) },
117
+ class_name: "Clickwrap::IntegrityAttestation",
118
+ foreign_key: :event_id,
119
+ inverse_of: :event,
120
+ dependent: :restrict_with_error
121
+
122
+ has_one :external_action,
123
+ class_name: "Clickwrap::ExternalAction",
124
+ foreign_key: :event_id,
125
+ inverse_of: :event,
126
+ dependent: :restrict_with_error
127
+
128
+ # --- Associations whose tables are optional -------------------------------
129
+ #
130
+ # `clickwrap:install` emits only the tables an installation can put a row
131
+ # in; the rest arrive with their own flag. Reading one of these associations
132
+ # on an installation that never created the table would read the schema of
133
+ # a table that deliberately does not exist — so each reader answers "there
134
+ # is nothing here", which is not a fallback but the exact truth: without the
135
+ # table, no row was ever written.
136
+ #
137
+ # Guarded here rather than at each call site, because the call sites are
138
+ # everywhere and the answer is a property of this record.
139
+ def presentation
140
+ super if SchemaRequirements.available?(:persisted_presentations)
141
+ end
142
+
143
+ def request_evidence
144
+ super if SchemaRequirements.available?(:request_evidence)
145
+ end
146
+
147
+ def integrity_attestations
148
+ return IntegrityAttestation.none unless SchemaRequirements.available?(:integrity)
149
+
150
+ super
151
+ end
152
+
153
+ def legal_holds
154
+ return LegalHold.none unless SchemaRequirements.available?(:retention_ops)
155
+
156
+ super
157
+ end
158
+
159
+ def external_action
160
+ super if SchemaRequirements.available?(:external_actions)
161
+ end
162
+
163
+ belongs_to :root_event, class_name: "Clickwrap::Event", optional: true
164
+ belongs_to :predecessor_event, class_name: "Clickwrap::Event", optional: true
165
+
166
+ has_many :successor_events,
167
+ class_name: "Clickwrap::Event",
168
+ foreign_key: :predecessor_event_id,
169
+ inverse_of: :predecessor_event,
170
+ dependent: :restrict_with_error
171
+
172
+ validates :event_type, inclusion: { in: Vocabulary::EVENT_TYPES }
173
+ validates :capture_channel, inclusion: { in: Vocabulary::CAPTURE_CHANNELS }
174
+ validates :attribution_method, inclusion: { in: Vocabulary::ATTRIBUTION_METHODS }
175
+ validates :policy_key, :actor_reference, :recorded_at_by_server, presence: true
176
+ validates :canonical_schema_version, :gem_version, presence: true
177
+ validate :represented_party_has_complete_authority
178
+
179
+ before_validation :assign_identifier, on: :create
180
+ before_validation :assign_retention_schedule_from_class, on: :create
181
+ before_create :assign_recording_sequence!
182
+ # Reserve a chain position before INSERTing this event or any autosaved
183
+ # statement/document rows. InnoDB can otherwise deadlock two first writers:
184
+ # each transaction holds evidence-row locks and then both try to create the
185
+ # same previously-absent chain head. Taking the one chain-head lock first
186
+ # gives PostgreSQL and MySQL the same lock order for captures, lifecycle
187
+ # events, and imports.
188
+ before_create :assign_chain_position!
189
+ before_update :refuse_ordinary_update
190
+ before_commit :ensure_integrity_was_finalized, on: :create
191
+
192
+ # `prepend: true` matters. The `dependent: :restrict_with_error` callbacks
193
+ # on the associations above are themselves `before_destroy` hooks, and they
194
+ # were registered first, so without prepending they would abort the destroy
195
+ # by returning false — quietly. A caller that does not check the return
196
+ # value would then believe it had deleted evidence. Raising first makes the
197
+ # refusal impossible to miss.
198
+ before_destroy :refuse_destroy, prepend: true
199
+
200
+ # Optional integrations run only after the required work has committed, and
201
+ # only ever after it. Registering this as an `after_commit` rather than
202
+ # calling it at the end of the capture matters when Clickwrap joined a
203
+ # host's transaction: at that point the capture has returned but the outer
204
+ # transaction has not committed, and a notification sent from inside a
205
+ # transaction that later rolls back announces something that never happened.
206
+ #
207
+ # A failure here is reported and swallowed. By the time it runs, the
208
+ # evidence and the action it protected are durable, and an analytics outage
209
+ # must not be able to undo them.
210
+ after_commit :at_apparent_commit_boundary, on: :create
211
+ after_rollback :invalidate_pending_receipts_after_rollback!, on: :create
212
+
213
+ scope :captures, -> { where(event_type: "capture") }
214
+ scope :human_actions, -> { where(event_type: Vocabulary::HUMAN_ACTION_EVENT_TYPES) }
215
+ scope :for_actor, ->(reference) { where(actor_reference: reference) }
216
+ scope :for_policy, ->(key) { where(policy_key: key.to_s) }
217
+ scope :for_subject_key, ->(key) { where(subject_key: key.to_s) }
218
+ scope :on_hold, -> { where(on_legal_hold: true) }
219
+ scope :not_disposed, -> { where(core_event_disposed_at: nil) }
220
+ # Server timestamps can tie or move backwards, and ULIDs are identifiers,
221
+ # not an ordering protocol. Events written after the ordering migration
222
+ # receive one database-generated sequence value. Upgrade migrations leave
223
+ # older, already-digested events nil on purpose: inventing a sequence later
224
+ # would rewrite their canonical body. Those legacy rows sort first using the
225
+ # same timestamp/id fallback the gem used before the stronger order existed.
226
+ scope :chronological, lambda {
227
+ order(Arel.sql("CASE WHEN recording_sequence IS NULL THEN 0 ELSE 1 END"))
228
+ .order(:recording_sequence, :recorded_at_by_server, :id)
229
+ }
230
+
231
+ scope :due_for_core_disposition, lambda { |at = Clickwrap.now|
232
+ not_disposed.where(on_legal_hold: false).where(retain_core_event_until: ..at)
233
+ }
234
+
235
+ # Questions an `after_event_is_committed` hook actually asks. A host wiring
236
+ # up "stop processing when someone withdraws" should not have to know that
237
+ # the answer is a string comparison against an event-type vocabulary.
238
+ def consent_was_withdrawn? = event_type == "withdrawal"
239
+ def consent_was_granted? = capture? && statements.any? { |s| s.kind == "consent" && s.answered? }
240
+ def declaration_was_corrected? = event_type == "correction"
241
+ def authorization_was_consumed? = event_type == "consumption"
242
+ def evidence_was_disposed? = event_type == "disposition"
243
+
244
+ # The purposes this event affected, for a hook that needs to know which
245
+ # processing to stop.
246
+ def purpose_keys = statements.filter_map(&:purpose_key).uniq
247
+
248
+ def capture? = event_type == "capture"
249
+ def imported? = %w[imported_legacy external_receipt].include?(event_type)
250
+ def exemption? = event_type == "exemption"
251
+ def human_action? = Vocabulary.human_action_event_type?(event_type)
252
+ def disposed? = core_event_disposed_at.present?
253
+ def held? = on_legal_hold?
254
+
255
+ def statement(statement_key)
256
+ statements.find { |candidate| candidate.statement_key == statement_key.to_s }
257
+ end
258
+
259
+ def policy = Clickwrap.policies[policy_key]
260
+
261
+ def compiled_policy_snapshot = policy_revision&.compiled_snapshot
262
+
263
+ def receipt = Receipt.new(self)
264
+
265
+ # The canonical body this event's digest covers. It deliberately excludes
266
+ # the mutable columns: whether a legal hold is currently in effect, and
267
+ # whether the optional annex has since been disposed of, are facts about
268
+ # today, not about what was recorded. Including them would make an ordinary
269
+ # retention run look like tampering.
270
+ def canonical_body
271
+ {
272
+ "schema" => canonical_schema_version,
273
+ "event_id" => id,
274
+ "event_type" => event_type,
275
+ "policy" => { "key" => policy_key, "revision" => policy_revision&.revision_digest }.compact,
276
+ "actor" => canonical_actor,
277
+ "tenant" => tenant_key,
278
+ "subject" => canonical_subject,
279
+ "capture_channel" => capture_channel,
280
+ "authentication_method" => authentication_method,
281
+ "authentication_context" => authentication_context.presence,
282
+ "recorded_at_by_server" => Receipt.format_time(recorded_at_by_server),
283
+ "occurred_at" => Receipt.format_time(occurred_at),
284
+ "idempotency_key" => idempotency_key,
285
+ "http_request_id" => http_request_id,
286
+ "http_route_name" => http_route_name,
287
+ "acts" => statements.map(&:canonical_fragment),
288
+ "documents" => documents.map(&:canonical_fragment),
289
+ "presentation" => canonical_presentation,
290
+ "protected_outcome" => protected_outcome.presence,
291
+ "provider" => canonical_provider,
292
+ "request_evidence" => canonical_request_evidence_binding,
293
+ "predecessor_event_id" => predecessor_event_id,
294
+ "root_event_id" => root_event_id,
295
+ "reason" => reason,
296
+ "retention" => {
297
+ "class" => retention_class_key,
298
+ "retain_core_event_until" => Receipt.format_time(retain_core_event_until),
299
+ "rule" => retention_rule_name
300
+ }.compact.presence,
301
+ "chain" => {
302
+ "scope" => chain_scope,
303
+ "sequence" => chain_sequence,
304
+ "previous_event_digest" => previous_event_digest
305
+ }.compact.presence,
306
+ "gem_version" => gem_version,
307
+ "application_version" => application_version,
308
+ "template_version" => template_version,
309
+ "created_at" => Receipt.format_time(created_at)
310
+ }.compact
311
+ end
312
+
313
+ def compute_digest
314
+ Digest.digest_canonical(canonical_body, algorithm: digest_algorithm || "sha256")
315
+ end
316
+
317
+ # Recomputes the digest and compares it with the one recorded at write time.
318
+ # A false here means the row's meaningful bytes changed since it was
319
+ # written; it does not, on its own, say who changed them or when.
320
+ def digest_verified?
321
+ return false if event_digest.blank?
322
+
323
+ Digest.secure_compare?(compute_digest, event_digest)
324
+ end
325
+
326
+ # A disposed core payload cannot be recomputed: the point of disposition is
327
+ # that those bytes are gone. Keep that state separate from a verifying
328
+ # digest. A valid, digest-bound disposition event can account for the
329
+ # mismatch, while an unexplained marker or altered row remains a failure.
330
+ def digest_integrity_status
331
+ if disposed?
332
+ return :documented_core_disposition if documented_core_disposition?
333
+
334
+ return :unaccounted_mismatch
335
+ end
336
+
337
+ return :verified if digest_verified?
338
+
339
+ :unaccounted_mismatch
340
+ end
341
+
342
+ def digest_integrity_accounted_for?
343
+ digest_integrity_status != :unaccounted_mismatch
344
+ end
345
+
346
+ # The optional annex has its own keyed binding. A permitted category
347
+ # disposition makes the original HMAC no longer recomputable; that state is
348
+ # accepted only when every deletion timestamp has a valid linked
349
+ # disposition event naming the category.
350
+ def request_evidence_binding_status
351
+ annex = request_evidence
352
+ digests = request_evidence_category_binding_digests.to_h
353
+ has_binding = digests.present? || request_evidence_key_id.present? ||
354
+ request_evidence_digest_algorithm.present?
355
+
356
+ return :not_recorded if annex.nil? && !has_binding
357
+ return :missing_annex_or_binding if annex.nil? || !has_binding
358
+ return :missing_annex_or_binding unless digests.keys.sort == RequestEvidence::CATEGORIES.map(&:to_s).sort
359
+
360
+ return :binding_key_unavailable unless annex.binding_key_available?(request_evidence_key_id)
361
+
362
+ disposed = false
363
+ RequestEvidence::CATEGORIES.each do |category|
364
+ if annex.deleted_for?(category)
365
+ disposed = true
366
+ return :undocumented_disposition unless request_evidence_disposition_documented?(annex, category)
367
+
368
+ next
369
+ end
370
+
371
+ verified = annex.category_binding_digest_verified?(
372
+ category: category,
373
+ digest: digests[category.to_s],
374
+ algorithm: request_evidence_digest_algorithm,
375
+ key_id: request_evidence_key_id
376
+ )
377
+ return :digest_mismatch unless verified
378
+ end
379
+
380
+ disposed ? :disposed_with_documented_events : :verified
381
+ rescue ::ActiveRecord::Encryption::Errors::Base
382
+ # Losing or rotating away an encryption key is different from a digest
383
+ # mismatch. The annex cannot currently be read, so verification reports
384
+ # that limited fact instead of crashing or calling the bytes modified.
385
+ :annex_unreadable
386
+ end
387
+
388
+ def evidence_integrity_verified?
389
+ digest_verified? && %i[
390
+ not_recorded verified disposed_with_documented_events
391
+ ].include?(request_evidence_binding_status)
392
+ end
393
+
394
+ # Finalization is deliberately explicit and happens only after every child
395
+ # statement/document, protected outcome, registration binding, retention
396
+ # decision, and chain position exists. Computing this in `before_create`
397
+ # produced digests for a half-built event and made normal successful
398
+ # captures fail their own integrity check.
399
+ def finalize_integrity!
400
+ raise EventWriteFailed, "Event #{id} has not been persisted, so it cannot be finalized." unless persisted?
401
+ if event_digest.present?
402
+ raise EventWriteFailed,
403
+ "Event #{id} was already finalized and cannot be finalized twice."
404
+ end
405
+
406
+ # Normally assigned by the before-create callback so chain contention is
407
+ # the first database lock this event takes. Keep this idempotent call as a
408
+ # defensive invariant for a host that deliberately bypassed callbacks
409
+ # while constructing an internal event.
410
+ assign_chain_position!
411
+ self.event_digest = compute_digest
412
+
413
+ update_columns(
414
+ chain_scope: chain_scope,
415
+ chain_sequence: chain_sequence,
416
+ previous_event_digest: previous_event_digest,
417
+ event_digest: event_digest,
418
+ digest_algorithm: digest_algorithm,
419
+ canonical_schema_version: canonical_schema_version
420
+ )
421
+
422
+ ChainHead.record!(chain_scope: chain_scope, event_id: id, event_digest: event_digest) if chain_scope.present?
423
+ self
424
+ end
425
+
426
+ def track_pending_receipt(pending_receipt)
427
+ (@pending_receipts ||= []) << pending_receipt
428
+ pending_receipt
429
+ end
430
+
431
+ # Called only once durable commit is no longer capable of being rolled
432
+ # back. Public because DurableCommitCallback invokes it through Active
433
+ # Record's transaction-record protocol; it is not host application API.
434
+ def finalize_durable_commit!
435
+ commit_pending_receipts
436
+ run_after_commit_hook
437
+ run_integrity_attestors
438
+ self
439
+ end
440
+
441
+ def invalidate_pending_receipts_after_rollback!
442
+ Array(@pending_receipts).each(&:mark_rolled_back!)
443
+ self
444
+ end
445
+
446
+ # --- The two permitted mutations -----------------------------------------
447
+
448
+ # Marks the core event as disposed of under a retention rule. The row stays:
449
+ # what disappears is the payload, and the disposition is itself recorded as
450
+ # a linked event, so an auditor sees a documented deletion rather than a gap.
451
+ def dispose_core_payload!(disposition_event:, at: Clickwrap.now)
452
+ transaction do
453
+ # Mark and link the documented disposition first. The PostgreSQL
454
+ # hardening tier permits child DELETEs only while their parent carries
455
+ # this marker. If any later deletion fails, this transaction rolls the
456
+ # marker back as well, so callers can never observe a half-disposed row.
457
+ update_columns(
458
+ actor_type: nil,
459
+ actor_id: nil,
460
+ actor_reference: "",
461
+ actor_snapshot: {},
462
+ represented_party_type: nil,
463
+ represented_party_id: nil,
464
+ represented_party_reference: "",
465
+ authority_source: nil,
466
+ authority_role: nil,
467
+ authority_verified_at: nil,
468
+ authority_details: {},
469
+ tenant_key: "",
470
+ subject_type: nil,
471
+ subject_id: nil,
472
+ subject_key: "",
473
+ subject_fingerprint: nil,
474
+ authentication_method: nil,
475
+ authentication_context: {},
476
+ idempotency_key: nil,
477
+ http_request_id: nil,
478
+ http_route_name: nil,
479
+ presentation_id: nil,
480
+ presentation_manifest: nil,
481
+ presentation_manifest_digest: nil,
482
+ protected_outcome: nil,
483
+ provider_receipt: nil,
484
+ provider_verification: nil,
485
+ reason: nil,
486
+ core_event_disposed_at: at,
487
+ core_event_disposition_event_id: disposition_event.id
488
+ )
489
+
490
+ # Calling `delete_all` through a `has_many` association asks Active
491
+ # Record to null the foreign key. These evidence children have an
492
+ # intentionally non-null foreign key, so issue real, scoped DELETEs.
493
+ EventStatement.where(event_id: id).delete_all
494
+ EventDocument.where(event_id: id).delete_all
495
+ # A root and its later lifecycle events are independently retained
496
+ # evidence payloads. Disposing the root must not erase a projection that
497
+ # now points at a still-retained correction, renewal, withdrawal, or
498
+ # other successor. Remove only projections for which this exact event is
499
+ # current; a later successor removes its own projection when its own
500
+ # schedule becomes due.
501
+ StatementState.where(current_event_id: id).delete_all
502
+ end
503
+ self
504
+ end
505
+
506
+ def documented_core_disposition?
507
+ return false unless disposed? && core_event_disposition_event_id.present?
508
+
509
+ disposition = Event.find_by(id: core_event_disposition_event_id)
510
+ facts = disposition&.protected_outcome.to_h["core_event_disposition"].to_h
511
+ disposition_event_links_to_self?(disposition) && disposition.digest_verified? &&
512
+ facts["event_id"] == id &&
513
+ facts["original_event_digest"] == event_digest &&
514
+ facts["disposed_at"] == Receipt.format_time(core_event_disposed_at)
515
+ end
516
+
517
+ def set_legal_hold!(held)
518
+ update_columns(on_legal_hold: held)
519
+ end
520
+
521
+ # Only the foreign key. The binding digest was written when the event was
522
+ # created, because it is part of the canonical body the event digest covers;
523
+ # setting it afterwards would leave every event with request evidence
524
+ # failing its own verification. `request_evidence_id` is safe to set here
525
+ # precisely because it is NOT in the canonical body — it is a pointer, not
526
+ # a fact about what was recorded.
527
+ def attach_request_evidence!(record)
528
+ update_columns(request_evidence_id: record.id)
529
+ end
530
+
531
+ def to_s = "#{event_type} #{policy_key} #{id}"
532
+
533
+ private
534
+
535
+ def assign_identifier
536
+ self.id ||= Identifier.generate(recorded_at_by_server || Clickwrap.now)
537
+ self.canonical_schema_version ||= Clickwrap::CANONICAL_SCHEMA_VERSION
538
+ self.gem_version ||= Clickwrap::VERSION
539
+ self.digest_algorithm ||= Clickwrap.config.digest_canonical_receipts_with.to_s
540
+ end
541
+
542
+ # Freeze every event's core-payload schedule when the event is written.
543
+ # Captures, imports, exemptions, and lifecycle events all pass through this
544
+ # callback, so none silently falls back to a retention class that may have
545
+ # changed years later. Each lifecycle event starts from its own
546
+ # `recorded_at_by_server`; disposing a root therefore never implies that a
547
+ # later successor is due too.
548
+ #
549
+ # Disposition events deliberately carry no further disposal schedule. Their
550
+ # small, digest-bound payload is the tombstone that explains why an earlier
551
+ # event no longer has its original body. Disposing that explanation would
552
+ # make the earlier lawful deletion indistinguishable from damage and would
553
+ # create an endless chain of disposition-of-disposition events.
554
+ def assign_retention_schedule_from_class
555
+ return if retention_class_key.blank?
556
+ return if event_type == "disposition"
557
+ return if retain_core_event_until.present? || retention_rule_name.present?
558
+
559
+ rule = Clickwrap.retention_class!(retention_class_key).rule_for(:core_event)
560
+ return if rule.nil?
561
+
562
+ if rule.duration?
563
+ self.retain_core_event_until = recorded_at_by_server + rule.duration
564
+ else
565
+ self.retention_rule_name = rule.host_event_name.to_s
566
+ end
567
+ end
568
+
569
+ # Deliberately built from `actor_reference` alone, not from the polymorphic
570
+ # `actor_type`/`actor_id` columns.
571
+ #
572
+ # Those columns are a convenience pointer at a row that may not always
573
+ # exist: deleting an account nullifies them, by design, because evidence
574
+ # must outlive the account it describes. If the digest covered them, an
575
+ # ordinary account deletion would make every one of that person's events
576
+ # fail verification — a lawful, expected operation looking exactly like
577
+ # tampering. The reference is the stable pseudonymous identity, and a
578
+ # GlobalID already carries the class inside it.
579
+ def canonical_actor
580
+ {
581
+ "reference" => actor_reference,
582
+ "attribution" => {
583
+ "method" => attribution_method,
584
+ "authenticated" => attribution_method == "authenticated_session"
585
+ },
586
+ "snapshot" => actor_snapshot.presence,
587
+ "represented_party" => if represented_party_reference.present?
588
+ {
589
+ "type" => represented_party_type,
590
+ "reference" => represented_party_reference
591
+ }.compact
592
+ end,
593
+ "authority" => {
594
+ "source" => authority_source,
595
+ "role" => authority_role,
596
+ "verified_at" => Receipt.format_time(authority_verified_at),
597
+ "details" => authority_details.presence
598
+ }.compact.presence
599
+ }.compact
600
+ end
601
+
602
+ def canonical_subject
603
+ return nil if subject_key.blank?
604
+
605
+ { "reference" => subject_key, "fingerprint" => subject_fingerprint }.compact
606
+ end
607
+
608
+ def canonical_presentation
609
+ return nil if presentation_manifest_digest.blank?
610
+
611
+ {
612
+ "manifest_digest" => presentation_manifest_digest,
613
+ "manifest" => presentation_manifest.presence
614
+ }.compact
615
+ end
616
+
617
+ def canonical_provider
618
+ return nil if provider_name.blank?
619
+
620
+ {
621
+ "name" => provider_name,
622
+ "event_id" => provider_event_id,
623
+ "receipt" => provider_receipt.presence,
624
+ "verification" => provider_verification.presence
625
+ }.compact
626
+ end
627
+
628
+ def canonical_request_evidence_binding
629
+ digests = request_evidence_category_binding_digests.to_h
630
+ return nil if digests.empty?
631
+
632
+ {
633
+ "category_digests" => digests,
634
+ "algorithm" => request_evidence_digest_algorithm,
635
+ "key_id" => request_evidence_key_id
636
+ }.compact
637
+ end
638
+
639
+ def assign_chain_position!
640
+ return unless Clickwrap.config.chain_event_history_with
641
+ return if chain_scope.present? || chain_sequence.present? || previous_event_digest.present?
642
+
643
+ self.chain_scope = [tenant_key.presence || "global", policy_key].join("/")
644
+ self.previous_event_digest, self.chain_sequence = ChainHead.reserve!(chain_scope: chain_scope)
645
+ end
646
+
647
+ def assign_recording_sequence!
648
+ self.recording_sequence ||= RecordingSequence.create!.id
649
+ end
650
+
651
+ # The recording sequence is DELIBERATELY absent from the canonical body and
652
+ # every receipt: it is the installation's private total order (a global
653
+ # counter), and publishing it would hand each receipt holder an enumerable
654
+ # census of how many events this installation records. Ordering questions
655
+ # are answered live against the database (`recorded_after?`), never off a
656
+ # receipt.
657
+
658
+ def ensure_integrity_was_finalized
659
+ return if event_digest.present? && digest_verified?
660
+
661
+ raise EventWriteFailed,
662
+ "Clickwrap event #{id} reached the commit boundary without a valid finalized digest. " \
663
+ "Every writer must append all covered facts and call `finalize_integrity!` before commit."
664
+ end
665
+
666
+ def represented_party_has_complete_authority
667
+ if represented_party_reference.blank?
668
+ facts = [authority_source, authority_role, authority_verified_at, authority_details.presence]
669
+ errors.add(:represented_party_reference, "is missing while authority facts are present") if facts.any?
670
+ return
671
+ end
672
+
673
+ missing = {
674
+ authority_source: authority_source,
675
+ authority_role: authority_role,
676
+ authority_verified_at: authority_verified_at
677
+ }.select { |_, value| value.blank? }.keys
678
+ return if missing.empty?
679
+
680
+ errors.add(:represented_party_reference, "requires #{missing.join(", ")}")
681
+ end
682
+
683
+ def commit_pending_receipts
684
+ Array(@pending_receipts).each(&:mark_committed!)
685
+ end
686
+
687
+ def run_after_commit_hook
688
+ Clickwrap.config.after_event_is_committed.call(self)
689
+ rescue StandardError => error
690
+ Clickwrap.report_after_commit_failure(error, self)
691
+ end
692
+
693
+ def run_integrity_attestors
694
+ Integrity::Attestor.attest_after_commit(self)
695
+ end
696
+
697
+ def at_apparent_commit_boundary
698
+ connection = ::ActiveRecord::Base.connection
699
+
700
+ if connection.transaction_open?
701
+ @durable_commit_callback ||= DurableCommitCallback.defer(self)
702
+ else
703
+ finalize_durable_commit!
704
+ end
705
+ end
706
+
707
+ def request_evidence_disposition_documented?(annex, category)
708
+ events = Event.where(root_event_id: root_event_id || id, event_type: "disposition").to_a
709
+
710
+ events.any? do |candidate|
711
+ disposition = candidate.protected_outcome.to_h["request_evidence_disposition"].to_h
712
+ disposition_event_links_to_self?(candidate) && candidate.digest_verified? &&
713
+ disposition["category"] == category.to_s &&
714
+ disposition["annex_id"].to_s == annex.id.to_s &&
715
+ disposition["disposed_at"].to_s == Receipt.format_time(annex.public_send(:"#{category}_deleted_at"))
716
+ end
717
+ end
718
+
719
+ def disposition_event_links_to_self?(candidate)
720
+ candidate&.event_type == "disposition" &&
721
+ candidate.predecessor_event_id.to_s == id.to_s &&
722
+ candidate.root_event_id.to_s == (root_event_id.presence || id).to_s
723
+ end
724
+
725
+ def refuse_ordinary_update
726
+ touched = changed - MUTABLE_COLUMNS
727
+ return if touched.empty?
728
+
729
+ raise EventWriteFailed,
730
+ "Clickwrap events refuse ordinary mutation, so #{touched.join(", ")} cannot be updated on " \
731
+ "event #{id}. Corrections, withdrawals, expiries, and supersessions are new linked " \
732
+ "events; that is what keeps a receipt able to show what was true at the time as " \
733
+ "well as what is true now."
734
+ end
735
+
736
+ def refuse_destroy
737
+ raise EventWriteFailed,
738
+ "Clickwrap events cannot be destroyed through the ordinary model API. Event #{id} " \
739
+ "must remain at its chain position. Disposition " \
740
+ "runs through Clickwrap::Retention with a reason and its own recorded event."
741
+ end
742
+ end
743
+ end