clickwrap 0.0.0 → 0.1.0

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 +612 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +241 -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 +138 -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 +184 -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 +716 -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,419 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # What the application configured, what one actor's evidence contains, and
5
+ # what a disposition of it would look like.
6
+ #
7
+ # ============================================================================
8
+ # THIS MODULE DESCRIBES A CONFIGURATION. Describing a configuration is not the
9
+ # same as the configuration being lawful, proportionate, or adequately
10
+ # justified. `inventory` reads back the fields this application chose to
11
+ # record, the purposes it wrote down, the references it supplied, and the
12
+ # periods it set. It does not evaluate any of them. A complete inventory with
13
+ # every field filled in is a well-documented configuration, and nothing more;
14
+ # the lawful basis, the necessity, the proportionality, the retention period,
15
+ # and the answer to an erasure request all belong to the host application and
16
+ # its counsel.
17
+ # ============================================================================
18
+ #
19
+ # Clickwrap::Privacy.inventory
20
+ # Clickwrap::Privacy.export_for(user, requested_by: current_operator)
21
+ # Clickwrap::Privacy.plan_disposition_for(user, requested_by: operator, because: "DSAR-2026-41")
22
+ module Privacy
23
+ CATEGORIES = %i[ip_address browser_user_agent ip_geolocation].freeze
24
+
25
+ # Used only to tell a host-supplied callback apart from the gem's own
26
+ # declining default when the inventory reports where a decision is made.
27
+ GEM_LIB_ROOT = File.expand_path("..", __dir__)
28
+
29
+ class << self
30
+ # A structured description of what this application configured: every
31
+ # policy, every personal or request-derived field it enables, the stated
32
+ # purpose, the host-supplied legal-basis and DPIA references, the
33
+ # resolver, the encryption state, the retention rule, the host events that
34
+ # cannot currently be resolved, and the review date.
35
+ def inventory
36
+ {
37
+ "describes" => "configuration",
38
+ "generated_at" => Receipt.format_time(Clickwrap.now),
39
+ "gem_version" => Clickwrap::VERSION,
40
+ "defaults" => default_inventory,
41
+ "access_decisions" => access_decisions,
42
+ "policies" => Clickwrap.policies.values.map { |policy| policy_inventory(policy) },
43
+ "retention_classes" => Clickwrap.retention_classes.values.map { |klass| retention_inventory(klass) },
44
+ "unresolved_host_events" => unresolved_host_events,
45
+ "means" => "The fields this application is configured to record, the purposes it wrote " \
46
+ "down, and the periods it set. Clickwrap does not assess whether any of them " \
47
+ "is lawful, necessary, or long enough."
48
+ }
49
+ end
50
+
51
+ # One actor's evidence, under exactly the authorization and redaction
52
+ # rules a receipt export uses — the same `because:`, the same host
53
+ # callback, the same field-by-field opt-in, and the same recorded access.
54
+ # There is deliberately no privacy-flavored shortcut that reveals more
55
+ # than `Clickwrap.export_receipt` would.
56
+ def export_for(actor, requested_by:, because: nil, include_ip_address: false,
57
+ include_browser_user_agent: false, include_ip_geolocation: false)
58
+ reference = reference_for!(actor)
59
+
60
+ events = Event.for_actor(reference)
61
+ .includes(:statements, :documents, :policy_revision, :request_evidence)
62
+ .chronological
63
+
64
+ {
65
+ "actor_reference" => reference,
66
+ "generated_at" => Receipt.format_time(Clickwrap.now),
67
+ "receipt_count" => events.size,
68
+ "receipts" => events.map do |event|
69
+ Receipt.export(
70
+ Receipt.new(event),
71
+ requested_by: requested_by,
72
+ because: because,
73
+ include_ip_address: include_ip_address,
74
+ include_browser_user_agent: include_browser_user_agent,
75
+ include_ip_geolocation: include_ip_geolocation
76
+ )
77
+ end,
78
+ "current_statements" => current_statements_for(reference),
79
+ "means" => "Every Clickwrap event recorded against this actor reference, rendered as " \
80
+ "receipts with the same redaction rules that apply everywhere else."
81
+ }
82
+ end
83
+
84
+ # Creates a reviewable disposition plan for one actor, and NOTHING else.
85
+ #
86
+ # It does not delete anything, does not release a hold, does not decide
87
+ # whether an erasure request overrides a retention duty, a legal claim, or
88
+ # a hold, and does not tell the host which of those apply. What it does is
89
+ # put the whole picture in one reviewable place: what exists, what is
90
+ # still inside its retention period, and what is held — so the person who
91
+ # has to make that decision makes it with the facts in front of them, and
92
+ # so their decision is recorded as a plan somebody else can read.
93
+ def plan_disposition_for(actor, requested_by:, because:)
94
+ require_reason!(because)
95
+ reference = reference_for!(actor)
96
+
97
+ items = actor_items(reference)
98
+ held, due = items.partition { |item| item.status == :held }
99
+
100
+ DispositionPlan.create!(
101
+ kind: "actor_privacy",
102
+ disposition_scope: {
103
+ "kind" => "actor_privacy",
104
+ "at" => Receipt.format_time(Clickwrap.now),
105
+ "actor_reference" => reference,
106
+ "items" => due.map(&:to_plan_entry)
107
+ },
108
+ summary: actor_summary(reference, due, held),
109
+ item_count: due.length,
110
+ created_by_reference: reference_for(requested_by),
111
+ reason: because
112
+ )
113
+ end
114
+
115
+ private
116
+
117
+ # --- Inventory ------------------------------------------------------------
118
+
119
+ def default_inventory
120
+ config = Clickwrap.config
121
+
122
+ {
123
+ "records_any_request_evidence_by_default" => config.records_any_request_evidence_by_default?,
124
+ "ip_address" => default_category(config, :ip_address),
125
+ "browser_user_agent" => default_category(config, :browser_user_agent),
126
+ "ip_geolocation" => default_category(config, :ip_geolocation).merge(
127
+ "fields" => config.enabled_default_ip_geolocation_fields,
128
+ "resolver" => describe_resolver(config.ip_geolocation_resolver),
129
+ "fail_capture_when_unavailable" => config.fail_capture_when_ip_geolocation_is_unavailable
130
+ ),
131
+ "review_default_request_evidence_configuration_on" =>
132
+ config.review_default_request_evidence_configuration_on&.to_s,
133
+ "ip_address_reader" => describe_callback(config.read_ip_address_from_http_request_with),
134
+ "trusted_proxy_configuration_digest" => config.trusted_proxy_configuration_digest,
135
+ "storing_request_evidence_unencrypted" => config.storing_request_evidence_unencrypted?,
136
+ "reason_for_storing_request_evidence_unencrypted" =>
137
+ config.reason_for_storing_request_evidence_unencrypted
138
+ }
139
+ end
140
+
141
+ def default_category(config, category)
142
+ {
143
+ "recorded_by_default" => default_recorded?(config, category),
144
+ "because" => config.public_send(:"reason_for_recording_#{plural_for(category)}_by_default"),
145
+ "legal_basis_reference" =>
146
+ config.public_send(:"legal_basis_reference_for_recording_#{plural_for(category)}_by_default"),
147
+ "encrypted" => config.public_send(:"encrypt_recorded_#{plural_for(category)}"),
148
+ "delete_after_seconds" => config.public_send(:"delete_recorded_#{plural_for(category)}_after")&.to_i
149
+ }
150
+ end
151
+
152
+ def default_recorded?(config, category)
153
+ case category
154
+ when :ip_address then config.record_ip_address_by_default
155
+ when :browser_user_agent then config.record_browser_user_agent_by_default
156
+ else config.enabled_default_ip_geolocation_fields.any?
157
+ end
158
+ end
159
+
160
+ def policy_inventory(policy)
161
+ request_evidence = policy.request_evidence
162
+ retention_class = Clickwrap.retention_classes[policy.retention_class_key.to_s]
163
+
164
+ {
165
+ "policy" => policy.key,
166
+ "retention_class" => policy.retention_class_key,
167
+ "records_any_request_evidence" => request_evidence.records_anything?,
168
+ "fields" => CATEGORIES.to_h do |category|
169
+ [category.to_s, policy_category(policy, category, retention_class)]
170
+ end,
171
+ "review_request_evidence_configuration_on" => request_evidence.review_configuration_on&.to_s,
172
+ "unresolved_host_events" => unresolved_host_events_for(policy, retention_class),
173
+ "persists_presentations_for_seconds" => policy.persist_presentations_for&.to_i,
174
+ "persists_presentations_because" => policy.persist_presentations_because
175
+ }
176
+ end
177
+
178
+ # One line per category per policy, with the purpose and the references
179
+ # the host supplied kept as the host's own words. Clickwrap stores them
180
+ # and prints them back; it never rewrites them into something that reads
181
+ # like an assessment it made.
182
+ def policy_category(policy, category, retention_class)
183
+ setting = policy.request_evidence.setting_for(category)
184
+ rule = retention_class&.rule_for(category)
185
+
186
+ entry = {
187
+ "recorded" => setting.record?,
188
+ "because" => setting.because,
189
+ "legal_basis_reference" => setting.legal_basis_reference,
190
+ "data_protection_impact_assessment_reference" =>
191
+ setting.data_protection_impact_assessment_reference,
192
+ "encrypted" => setting.encrypted?,
193
+ "delete_after_seconds" => setting.delete_after&.to_i,
194
+ "retain_until_rule" => setting.retain_until&.to_s,
195
+ "retention_class_rule" => describe_rule(rule),
196
+ "fail_capture_when_unavailable" => setting.fail_if_unavailable?
197
+ }
198
+
199
+ return entry unless category == :ip_geolocation
200
+
201
+ entry.merge(
202
+ "fields" => policy.request_evidence.enabled_ip_geolocation_fields,
203
+ "resolver_named_by_policy" => policy.request_evidence.ip_geolocation_resolver_name&.to_s,
204
+ "resolver" => describe_resolver(Clickwrap.config.ip_geolocation_resolver)
205
+ )
206
+ end
207
+
208
+ def retention_inventory(retention_class)
209
+ {
210
+ "retention_class" => retention_class.key,
211
+ "rules" => RetentionClass::PARTS.to_h do |part|
212
+ [part.to_s, describe_rule(retention_class.rule_for(part))]
213
+ end
214
+ }
215
+ end
216
+
217
+ # A rule reads back as what it is: a fixed period, or the name of a host
218
+ # calculation plus whether that calculation is currently registered. An
219
+ # unregistered name is reported rather than smoothed over — it is the
220
+ # difference between "not due yet" and "nothing can ever say when".
221
+ def describe_rule(rule)
222
+ return nil if rule.nil?
223
+ return { "kind" => "duration", "seconds" => rule.duration.to_i } if rule.duration?
224
+
225
+ {
226
+ "kind" => "host_event",
227
+ "host_event" => rule.host_event_name.to_s,
228
+ "calculation_is_registered" => registered_host_events.include?(rule.host_event_name.to_sym)
229
+ }
230
+ end
231
+
232
+ def unresolved_host_events
233
+ names = Clickwrap.retention_classes.values.flat_map do |retention_class|
234
+ RetentionClass::PARTS.filter_map do |part|
235
+ rule = retention_class.rule_for(part)
236
+ rule&.host_event? ? rule.host_event_name.to_sym : nil
237
+ end
238
+ end
239
+
240
+ (names.uniq - registered_host_events).map(&:to_s).sort
241
+ end
242
+
243
+ def unresolved_host_events_for(policy, retention_class)
244
+ from_policy = CATEGORIES.filter_map do |category|
245
+ retain_until = policy.request_evidence.setting_for(category).retain_until
246
+ retain_until&.to_sym
247
+ end
248
+
249
+ from_class = RetentionClass::PARTS.filter_map do |part|
250
+ rule = retention_class&.rule_for(part)
251
+ rule&.host_event? ? rule.host_event_name.to_sym : nil
252
+ end
253
+
254
+ ((from_policy + from_class).uniq - registered_host_events).map(&:to_s).sort
255
+ end
256
+
257
+ def registered_host_events = Clickwrap.config.retention_time_calculator_names
258
+
259
+ def access_decisions
260
+ config = Clickwrap.config
261
+
262
+ {
263
+ "authorize_receipt_access_with" => describe_callback(config.authorize_receipt_access_with),
264
+ "authorize_unredacted_request_evidence_access_with" =>
265
+ describe_callback(config.authorize_unredacted_request_evidence_access_with),
266
+ "identify_actor_with" => describe_callback(config.identify_actor_with),
267
+ "snapshot_actor_with" => describe_callback(config.snapshot_actor_with)
268
+ }
269
+ end
270
+
271
+ # Where the decision lives, and whether it is still Clickwrap's own
272
+ # declining default. A file and line number is the most useful thing an
273
+ # inventory can say about a callback: it points at the code somebody has
274
+ # to read to know what the application actually permits.
275
+ def describe_callback(callable)
276
+ location = callable.respond_to?(:source_location) ? callable.source_location : nil
277
+ return { "defined_at" => "unknown" } if location.nil?
278
+
279
+ {
280
+ "defined_at" => location.join(":"),
281
+ "is_clickwrap_default" => location.first.to_s.start_with?(GEM_LIB_ROOT)
282
+ }
283
+ end
284
+
285
+ def describe_resolver(resolver)
286
+ return { "configured" => false } if resolver.nil?
287
+
288
+ { "configured" => true, "class" => resolver.class.name }
289
+ end
290
+
291
+ def plural_for(category)
292
+ case category
293
+ when :ip_address then "ip_addresses"
294
+ when :browser_user_agent then "browser_user_agents"
295
+ else "ip_geolocation"
296
+ end
297
+ end
298
+
299
+ # --- Actor export and actor plans ----------------------------------------
300
+
301
+ def current_statements_for(reference)
302
+ StatementState.for_actor(reference).map do |state|
303
+ {
304
+ "policy" => state.policy_key,
305
+ "statement" => state.statement_key,
306
+ "kind" => state.kind,
307
+ "state" => state.state,
308
+ "effective_at" => Receipt.format_time(state.effective_at),
309
+ "expires_at" => Receipt.format_time(state.expires_at)
310
+ }.compact
311
+ end
312
+ end
313
+
314
+ def actor_items(reference)
315
+ holds = Retention::Planner::HoldIndex.load
316
+ items = []
317
+
318
+ Event.for_actor(reference).not_disposed.includes(:request_evidence).find_each do |event|
319
+ held = holds.held?(event)
320
+ items << actor_core_item(event, held)
321
+ items.concat(actor_annex_items(event, held))
322
+ end
323
+
324
+ items
325
+ end
326
+
327
+ def actor_core_item(event, held)
328
+ eligibility = Retention::Planner.core_event_eligibility(event)
329
+
330
+ actor_item(part: :core_event, event: event, record_id: event.id, held: held,
331
+ eligibility: eligibility)
332
+ end
333
+
334
+ def actor_annex_items(event, held)
335
+ annex = event.request_evidence
336
+ return [] if annex.nil?
337
+
338
+ CATEGORIES.filter_map do |category|
339
+ next if annex.deleted_for?(category)
340
+ next if annex.public_send(:"#{category}_recorded_at").nil?
341
+
342
+ actor_item(part: category, event: event, record_id: annex.id, held: held,
343
+ eligibility: Retention::Planner.annex_eligibility(annex, category))
344
+ end
345
+ end
346
+
347
+ # Every item carries the retention picture in its own detail line, so the
348
+ # reviewer sees exactly what they would be overriding. Clickwrap states
349
+ # the position; it does not resolve it.
350
+ def actor_item(part:, event:, record_id:, held:, eligibility:)
351
+ Retention::Planner::Item.new(
352
+ part: part,
353
+ status: held ? :held : :due,
354
+ event_id: event.id,
355
+ record_id: record_id,
356
+ policy_key: event.policy_key,
357
+ actor_reference: event.actor_reference,
358
+ retention_class_key: event.retention_class_key,
359
+ eligible_at: eligibility.eligible_at,
360
+ rule: eligibility.rule,
361
+ detail: actor_item_detail(held, eligibility),
362
+ eligibility: "actor_request"
363
+ )
364
+ end
365
+
366
+ def actor_item_detail(held, eligibility)
367
+ return "Under a legal hold. Not included in this plan." if held
368
+ return "Its retention rule cannot be resolved: #{eligibility.unresolved_reason}" if eligibility.unresolved?
369
+
370
+ if eligibility.eligible_at > Clickwrap.now
371
+ "Still inside its retention period, which ends #{Receipt.format_time(eligibility.eligible_at)}."
372
+ else
373
+ "Past its retention period, which ended #{Receipt.format_time(eligibility.eligible_at)}."
374
+ end
375
+ end
376
+
377
+ def actor_summary(reference, due, held)
378
+ {
379
+ "generated_at" => Receipt.format_time(Clickwrap.now),
380
+ "actor_reference" => reference,
381
+ "due" => due.length,
382
+ "held" => held.length,
383
+ "still_within_retention_period" => due.count { |item| item.eligible_at && item.eligible_at > Clickwrap.now },
384
+ "by_part" => due.group_by { |item| item.part.to_s }.transform_values(&:length),
385
+ "held_examples" => held.first(Retention::Planner::EXAMPLE_LIMIT).map(&:to_plan_entry),
386
+ "means" => "What exists for this actor, what is still inside its retention period, and " \
387
+ "what is under a legal hold. Applying this plan is a decision the host makes; " \
388
+ "Clickwrap does not decide whether an erasure request overrides a retention " \
389
+ "duty, a legal claim, or a hold."
390
+ }
391
+ end
392
+
393
+ # --- Shared ---------------------------------------------------------------
394
+
395
+ def reference_for(actor)
396
+ Reference.actor(actor)
397
+ end
398
+
399
+ def reference_for!(actor)
400
+ reference = reference_for(actor)
401
+ return reference if reference.present?
402
+
403
+ raise ArgumentError,
404
+ "Clickwrap needs an actor, or the actor reference string recorded in the evidence " \
405
+ "(for example \"gid://my-app/User/123\"). Evidence is keyed by that reference " \
406
+ "precisely so it survives the account row being deleted."
407
+ end
408
+
409
+ def require_reason!(because)
410
+ return unless because.to_s.strip.empty?
411
+
412
+ raise LifecycleError,
413
+ "Planning a disposition for an actor needs a `because:` naming the request it " \
414
+ "answers. It is stored on the plan, and it is what tells the next reviewer why " \
415
+ "somebody proposed deleting this."
416
+ end
417
+ end
418
+ end
419
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # Builds the exact result a protected database action records in its
5
+ # evidence event, and re-derives its digest so the recorded claim cannot be
6
+ # edited after the fact. What the digest binds is what the recorder
7
+ # returned — it does not independently verify the recorder told the truth.
8
+ # Hosts provide ordinary canonical facts; Clickwrap owns the stable
9
+ # reference and the digest over the complete claim.
10
+ #
11
+ # Clickwrap.protected_outcome(
12
+ # action: :submitted,
13
+ # record: withdrawal,
14
+ # state: withdrawal.status,
15
+ # facts: {
16
+ # amount_in_cents: withdrawal.amount_cents,
17
+ # currency: withdrawal.currency,
18
+ # destination_reference: withdrawal.destination_reference
19
+ # }
20
+ # )
21
+ #
22
+ # The fingerprint covers the action, record reference, state, and every fact.
23
+ # It detects later changes to that snapshot; it is not a signature, identity
24
+ # proof, trusted timestamp, or substitute for the linked domain record.
25
+ module ProtectedOutcome
26
+ REQUIRED_KEYS = %w[action facts fingerprint reference].freeze
27
+ OPTIONAL_KEYS = %w[state].freeze
28
+ PERMITTED_KEYS = (REQUIRED_KEYS + OPTIONAL_KEYS).freeze
29
+
30
+ class << self
31
+ def build(action:, record:, facts:, state: nil)
32
+ action = required_string(action, "action")
33
+ if record.respond_to?(:persisted?) && !record.persisted?
34
+ raise ArgumentError,
35
+ "A protected outcome needs a persisted `record:` with a stable reference. " \
36
+ "Save the result inside the protected-action block and return it."
37
+ end
38
+ reference = Reference.record(record)
39
+ if reference.blank?
40
+ raise ArgumentError,
41
+ "A protected outcome needs a persisted `record:` with a stable reference."
42
+ end
43
+
44
+ normalized_facts = normalize_facts(facts)
45
+ claim = {
46
+ "action" => action,
47
+ "reference" => reference,
48
+ "facts" => normalized_facts
49
+ }
50
+ claim["state"] = required_string(state, "state") unless state.nil?
51
+ claim["fingerprint"] = fingerprint_for(claim)
52
+ claim.freeze
53
+ end
54
+
55
+ def validate!(outcome)
56
+ unless outcome.is_a?(Hash)
57
+ raise DefinitionError,
58
+ "A protected-outcome recorder must return `Clickwrap.protected_outcome(...)`, " \
59
+ "which returns a Hash, but it returned #{outcome.class}."
60
+ end
61
+
62
+ normalized = outcome.deep_stringify_keys
63
+ unknown = normalized.keys - PERMITTED_KEYS
64
+ missing = REQUIRED_KEYS - normalized.keys
65
+ if unknown.any? || missing.any?
66
+ details = []
67
+ details << "missing #{missing.join(", ")}" if missing.any?
68
+ details << "unknown #{unknown.join(", ")}" if unknown.any?
69
+ raise DefinitionError,
70
+ "The protected outcome has #{details.join("; ")}. Build it with " \
71
+ "`Clickwrap.protected_outcome(action:, record:, facts:, state: nil)` so its " \
72
+ "meaning and fingerprint are complete."
73
+ end
74
+
75
+ required_string(normalized["action"], "action")
76
+ required_string(normalized["reference"], "reference")
77
+ required_string(normalized["state"], "state") if normalized.key?("state")
78
+ normalized["facts"] = normalize_facts(normalized["facts"])
79
+
80
+ expected = fingerprint_for(normalized.except("fingerprint"))
81
+ unless Digest.secure_compare?(normalized["fingerprint"], expected)
82
+ raise DefinitionError,
83
+ "The protected outcome fingerprint does not match its action, record reference, " \
84
+ "state, and facts. Build the outcome after the protected action finishes by " \
85
+ "calling `Clickwrap.protected_outcome(...)`."
86
+ end
87
+
88
+ normalized
89
+ rescue CanonicalJson::SerializationError => error
90
+ raise DefinitionError,
91
+ "Protected-outcome facts must be canonical JSON values: #{error.message}"
92
+ end
93
+
94
+ private
95
+
96
+ def normalize_facts(facts)
97
+ unless facts.is_a?(Hash) && facts.any?
98
+ raise ArgumentError,
99
+ "A protected outcome needs a non-empty `facts:` hash containing the exact " \
100
+ "business facts the action committed."
101
+ end
102
+
103
+ normalized = facts.deep_stringify_keys
104
+ CanonicalJson.generate(normalized)
105
+ normalized
106
+ end
107
+
108
+ def fingerprint_for(claim)
109
+ Digest.digest_canonical(claim)
110
+ end
111
+
112
+ def required_string(value, name)
113
+ normalized = value.to_s
114
+ return normalized if normalized.present?
115
+
116
+ raise ArgumentError, "A protected outcome needs a non-blank `#{name}:`."
117
+ end
118
+ end
119
+ end
120
+ end