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,569 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ # The operator surface: everything you might need to run at 03:00, in one
7
+ # namespace, each task printing what it found in full sentences.
8
+ #
9
+ # Two rules hold across every task here.
10
+ #
11
+ # 1. NOTHING PRINTS A VERDICT. These tasks report configuration and data facts.
12
+ # None of them says "compliant", "court-proof", or "audit guaranteed", and
13
+ # none of them ever will — a task that printed a green verdict would be
14
+ # making a legal determination on a host's behalf, which is the one thing
15
+ # this gem exists not to do.
16
+ #
17
+ # 2. DESTRUCTIVE WORK IS TWO STEPS. `retention:plan` writes a plan and deletes
18
+ # nothing. `retention:apply` requires that plan's id explicitly: there is no
19
+ # `--all`, no `--yes`, and no way to apply "whatever is due right now"
20
+ # without a human having looked at the list first.
21
+ module ClickwrapTasks
22
+ module_function
23
+
24
+ def say(text = "") = $stdout.puts(text)
25
+
26
+ def heading(text)
27
+ say
28
+ say(text)
29
+ say("=" * text.length)
30
+ end
31
+
32
+ def env(name)
33
+ value = ENV.fetch(name, nil).to_s.strip
34
+ value.empty? ? nil : value
35
+ end
36
+
37
+ def env!(name, usage)
38
+ env(name) || abort("#{name} is required.\n\n #{usage}\n")
39
+ end
40
+
41
+ def flag?(name) = %w[1 true yes on].include?(env(name).to_s.downcase)
42
+
43
+ def json? = (env("FORMAT") || "").downcase == "json"
44
+
45
+ def dump(object) = say(JSON.pretty_generate(object))
46
+
47
+ def count(number, singular, plural = "#{singular}s")
48
+ "#{number} #{number == 1 ? singular : plural}"
49
+ end
50
+
51
+ def list(entries)
52
+ entries.each { |entry| say(" #{entry}") }
53
+ say(" (none)") if entries.empty?
54
+ end
55
+
56
+ def remediation_route
57
+ return "Clickwrap::Engine is mounted, so gated policies have a capture screen to redirect to" if
58
+ Clickwrap::ControllerHelpers.engine_is_mounted?
59
+
60
+ "Clickwrap::Engine is NOT mounted, so every requires_clickwrap gate needs its own remediation_path:"
61
+ end
62
+
63
+ # Who would be asked to act again if a newer required version were published:
64
+ # the actors whose recorded evidence cites a document version that is no
65
+ # longer the current one.
66
+ #
67
+ # Read row by row rather than in SQL. The recorded version ids live in a JSON
68
+ # column, every adapter spells JSON containment differently, and an operator
69
+ # preview is not the place to find that out.
70
+ def reacceptance_report(policy, statement)
71
+ current = statement.document_keys.to_h do |document_key|
72
+ document = Clickwrap::Document.find_by(document_key: document_key)
73
+ version = document&.current_version
74
+ [document_key, version&.id&.to_s]
75
+ end
76
+
77
+ affected = Set.new
78
+ total = 0
79
+
80
+ states_for(policy, statement).find_each do |state|
81
+ total += 1
82
+ recorded = Array(state.document_version_ids).map(&:to_s)
83
+ affected << state.actor_reference if current.values.compact.any? { |id| !recorded.include?(id) }
84
+ end
85
+
86
+ say(" #{statement.key} (#{statement.kind})")
87
+ current.each { |key, id| say(" current published version of #{key}: #{id || "(nothing published)"}") }
88
+ say(" actors with current evidence: #{total}")
89
+ say(" actors who would be asked again: #{affected.size}")
90
+ end
91
+
92
+ def states_for(policy, statement)
93
+ Clickwrap::StatementState.for_policy(policy.key).for_statement(statement.key).active
94
+ end
95
+
96
+ # Recomputes event digests. Whole table by default; SINCE and LIMIT narrow it,
97
+ # because "verify everything" and "verify what happened since last night" are
98
+ # both things an operator legitimately wants at 03:00.
99
+ def digest_sweep
100
+ checked = 0
101
+ verified = []
102
+ documented_dispositions = []
103
+ failed = []
104
+
105
+ check = lambda do |event|
106
+ checked += 1
107
+
108
+ case event.digest_integrity_status
109
+ when :verified
110
+ verified << event.id
111
+ when :documented_core_disposition
112
+ documented_dispositions << event.id
113
+ else
114
+ failed << event.id
115
+ end
116
+ end
117
+
118
+ events = sweep_scope
119
+ events.respond_to?(:find_each) ? events.find_each(&check) : events.each(&check)
120
+
121
+ {
122
+ "checked" => checked,
123
+ "verified" => verified.length,
124
+ "documented_dispositions" => documented_dispositions,
125
+ "failed" => failed
126
+ }
127
+ end
128
+
129
+ def sweep_scope
130
+ scope = Clickwrap::Event.all
131
+ since = env("SINCE")
132
+ scope = scope.where(recorded_at_by_server: Time.parse(since)..) if since
133
+ limit = env("LIMIT")
134
+
135
+ limit ? scope.order(id: :desc).limit(limit.to_i).to_a : scope
136
+ end
137
+ end
138
+
139
+ namespace :clickwrap do
140
+ desc "Report objective configuration and data facts about this installation"
141
+ task doctor: :environment do
142
+ findings = Clickwrap::Doctor.new.report
143
+
144
+ if ClickwrapTasks.json?
145
+ ClickwrapTasks.dump(findings.map { |f| { "status" => f.status.to_s, "message" => f.message } })
146
+ else
147
+ findings.each { |finding| ClickwrapTasks.say(finding.to_s) }
148
+ end
149
+
150
+ # An objective failure — a document whose bytes no longer match, a digest
151
+ # that does not recompute — exits non-zero so a monitor notices. That is a
152
+ # statement about this installation's data, not about anybody's legal
153
+ # position.
154
+ exit(1) if findings.any?(&:problem?)
155
+ end
156
+
157
+ desc "Publish every declared document version that has not been published yet"
158
+ task publish: :environment do
159
+ outcomes = Clickwrap.publish!
160
+
161
+ ClickwrapTasks.heading("Publishing documents")
162
+ outcomes.each { |outcome| ClickwrapTasks.say(" #{outcome.status}: #{outcome.definition} — #{outcome.message}") }
163
+ ClickwrapTasks.say
164
+ ClickwrapTasks.say("#{ClickwrapTasks.count(outcomes.count(&:published?), "document")} published, " \
165
+ "#{outcomes.count(&:unchanged?)} already published with identical bytes.")
166
+ end
167
+
168
+ namespace :publish do
169
+ desc "Show what clickwrap:publish would do, without writing anything"
170
+ task plan: :environment do
171
+ outcomes = Clickwrap.publish!(dry_run: true)
172
+
173
+ ClickwrapTasks.heading("Publishing plan (nothing was written)")
174
+ outcomes.each do |outcome|
175
+ ClickwrapTasks.say(" #{outcome.status}: #{outcome.definition} — #{outcome.message}")
176
+ end
177
+ end
178
+ end
179
+
180
+ namespace :reacceptance do
181
+ desc "Preview who a newly published version would require to act again (POLICY=key)"
182
+ task plan: :environment do
183
+ key = ClickwrapTasks.env!("POLICY", "bin/rails clickwrap:reacceptance:plan POLICY=current_terms")
184
+ policy = Clickwrap.policy!(key)
185
+ statements = policy.statements.select(&:requires_current_version?)
186
+
187
+ ClickwrapTasks.heading("Reacceptance plan for #{policy.key}")
188
+
189
+ if statements.empty?
190
+ ClickwrapTasks.say(" No statement in this policy declares `require_current_version: true`, so")
191
+ ClickwrapTasks.say(" publishing a new document version does not require anyone to act again.")
192
+ else
193
+ statements.each { |statement| ClickwrapTasks.reacceptance_report(policy, statement) }
194
+ end
195
+
196
+ ClickwrapTasks.say
197
+ ClickwrapTasks.say("Remediation route: #{ClickwrapTasks.remediation_route}")
198
+ ClickwrapTasks.say("Nothing was emailed, nothing changed, and Clickwrap does not decide whether")
199
+ ClickwrapTasks.say("this change is material. That is the application's call.")
200
+ end
201
+ end
202
+
203
+ desc "Verify recorded evidence: one event with EVENT_ID, or the whole chain and digests"
204
+ task verify: :environment do
205
+ event_id = ClickwrapTasks.env("EVENT_ID")
206
+
207
+ if event_id
208
+ event = Clickwrap::Event.find_by(id: event_id) || abort("No Clickwrap event with id #{event_id}.")
209
+ result = Clickwrap.verify(event.id)
210
+ report = {
211
+ "event_id" => event.id,
212
+ "event_type" => event.event_type,
213
+ "policy" => event.policy_key,
214
+ "digest_verifies" => event.digest_verified?,
215
+ "documented_core_disposition" => event.documented_core_disposition?,
216
+ "verification" => result.to_h
217
+ }
218
+
219
+ if ClickwrapTasks.json?
220
+ ClickwrapTasks.dump(report)
221
+ else
222
+ ClickwrapTasks.heading("Event #{event.id}")
223
+ ClickwrapTasks.say(" policy: #{event.policy_key} (#{event.event_type})")
224
+ ClickwrapTasks.say(" recorded by server: #{event.recorded_at_by_server}")
225
+ ClickwrapTasks.say(" digest verifies: #{event.digest_verified?}")
226
+ ClickwrapTasks.say(" core disposition documented: #{event.documented_core_disposition?}")
227
+ ClickwrapTasks.say(" verification: #{result.success? ? "satisfied" : result.error}")
228
+ end
229
+
230
+ exit(1) unless event.digest_integrity_accounted_for?
231
+ else
232
+ chain = Clickwrap::Integrity::Chain.verify
233
+ digests = ClickwrapTasks.digest_sweep
234
+
235
+ if ClickwrapTasks.json?
236
+ ClickwrapTasks.dump("chain" => chain.to_h, "digests" => digests)
237
+ else
238
+ ClickwrapTasks.heading("Chain verification")
239
+ ClickwrapTasks.say(" chaining enabled: #{chain.chaining_enabled}")
240
+ ClickwrapTasks.say(" scopes walked: #{chain.scopes.length}")
241
+ ClickwrapTasks.say(" events checked: #{chain.checked}")
242
+ ClickwrapTasks.say(" first break: #{chain.first_break || "none"}")
243
+ ClickwrapTasks.say
244
+ ClickwrapTasks.heading("Event digests")
245
+ ClickwrapTasks.say(" events checked: #{digests["checked"]}")
246
+ ClickwrapTasks.say(" digests verifying: #{digests["verified"]}")
247
+ ClickwrapTasks.say(" documented core dispositions: #{digests["documented_dispositions"].length}")
248
+ ClickwrapTasks.say(" unexplained mismatches: #{digests["failed"].length}")
249
+ ClickwrapTasks.list(digests["failed"].first(20))
250
+ ClickwrapTasks.say
251
+ ClickwrapTasks.say("A verifying digest detects accidental or ordinary modification of the bytes it")
252
+ ClickwrapTasks.say("covers. It does not establish who produced them or when.")
253
+ end
254
+
255
+ exit(1) unless chain.success? && digests["failed"].empty?
256
+ end
257
+ end
258
+
259
+ namespace :integrity do
260
+ desc "Record configured timestamp/anchor attempts missing after committed events (may call providers)"
261
+ task attest_missing: :environment do
262
+ result = Clickwrap.reconcile_missing_integrity_attestations!(
263
+ scope: ClickwrapTasks.sweep_scope,
264
+ retry_failed_attestations: ClickwrapTasks.flag?("RETRY_FAILED_ATTESTATIONS")
265
+ )
266
+
267
+ if ClickwrapTasks.json?
268
+ ClickwrapTasks.dump(result.to_h)
269
+ else
270
+ ClickwrapTasks.heading("Missing integrity attestations")
271
+ ClickwrapTasks.say(" attempts made: #{result.attempted}")
272
+ ClickwrapTasks.say(" results recorded: #{result.recorded}")
273
+ ClickwrapTasks.say(" results not recorded: #{result.not_recorded}")
274
+ ClickwrapTasks.say
275
+ ClickwrapTasks.say("This task may call external providers. After a crash, a provider may have accepted")
276
+ ClickwrapTasks.say("a request before its local result was recorded, so adapters should treat the event")
277
+ ClickwrapTasks.say("digest or exact chain snapshot as an idempotency key. This is not exactly-once delivery.")
278
+ end
279
+
280
+ exit(1) unless result.clean?
281
+ end
282
+ end
283
+
284
+ desc "Print one receipt as canonical JSON (EVENT_ID=..., sensitive fields opt in by name)"
285
+ task export: :environment do
286
+ event_id = ClickwrapTasks.env!("EVENT_ID", "bin/rails clickwrap:export EVENT_ID=01K2Y8T5QY0N4V6N1H4G4CQY8J")
287
+ receipt = Clickwrap.receipt(event_id)
288
+
289
+ ClickwrapTasks.dump(
290
+ Clickwrap.export_receipt(
291
+ receipt,
292
+ requested_by: ClickwrapTasks.env("REQUESTED_BY"),
293
+ because: ClickwrapTasks.env("BECAUSE"),
294
+ include_ip_address: ClickwrapTasks.flag?("INCLUDE_IP_ADDRESS"),
295
+ include_browser_user_agent: ClickwrapTasks.flag?("INCLUDE_BROWSER_USER_AGENT"),
296
+ include_ip_geolocation: ClickwrapTasks.flag?("INCLUDE_IP_GEOLOCATION")
297
+ )
298
+ )
299
+ end
300
+
301
+ namespace :retention do
302
+ desc "Build a reviewable disposition plan. Deletes nothing."
303
+ task plan: :environment do
304
+ plan = Clickwrap::Retention::Planner.new(
305
+ policy_key: ClickwrapTasks.env("POLICY"),
306
+ actor_reference: ClickwrapTasks.env("ACTOR"),
307
+ created_by: ClickwrapTasks.env("BY"),
308
+ because: ClickwrapTasks.env("BECAUSE")
309
+ ).call
310
+
311
+ summary = plan.summary.to_h
312
+
313
+ if ClickwrapTasks.json?
314
+ ClickwrapTasks.dump("plan_id" => plan.id, "expires_at" => plan.expires_at.to_s, "summary" => summary)
315
+ else
316
+ ClickwrapTasks.heading("Disposition plan #{plan.id}")
317
+ ClickwrapTasks.say(" due: #{summary["due"]}")
318
+ ClickwrapTasks.say(" held: #{summary["held"]} (a legal hold is pausing these)")
319
+ ClickwrapTasks.say(" unresolved: #{summary["unresolved"]} (a host event has not happened yet)")
320
+ ClickwrapTasks.say
321
+ ClickwrapTasks.say(" by part:")
322
+ summary["by_part"].each { |part, counts| ClickwrapTasks.say(" #{part}: #{counts.inspect}") }
323
+ ClickwrapTasks.say(" by policy:")
324
+ summary["by_policy"].each { |policy, counts| ClickwrapTasks.say(" #{policy}: #{counts.inspect}") }
325
+ ClickwrapTasks.say
326
+ ClickwrapTasks.say("Nothing has been deleted. Review the plan, then:")
327
+ ClickwrapTasks.say(" bin/rails clickwrap:retention:apply PLAN=#{plan.id}")
328
+ ClickwrapTasks.say("The plan expires at #{plan.expires_at}, and is re-checked item by item when applied.")
329
+ end
330
+ end
331
+
332
+ desc "Apply one reviewed disposition plan (PLAN=id). Deletes evidence."
333
+ task apply: :environment do
334
+ plan_id = ClickwrapTasks.env!(
335
+ "PLAN",
336
+ "bin/rails clickwrap:retention:apply PLAN=01K2Y8T5QY0N4V6N1H4G4CQY8J (run clickwrap:retention:plan first)"
337
+ )
338
+
339
+ plan = Clickwrap::DispositionPlan.find_by(id: plan_id) || abort("No disposition plan with id #{plan_id}.")
340
+ result = Clickwrap::Retention::Applier.new(plan, applied_by: ClickwrapTasks.env("BY")).call
341
+
342
+ if ClickwrapTasks.json?
343
+ ClickwrapTasks.dump(result.to_h)
344
+ else
345
+ ClickwrapTasks.heading("Applied disposition plan #{plan.id}")
346
+ result.counts.each { |name, number| ClickwrapTasks.say(" #{name.ljust(16)} #{number}") }
347
+ ClickwrapTasks.say
348
+ ClickwrapTasks.say(" skipped because a legal hold is in effect:")
349
+ ClickwrapTasks.list(result.skipped_held.first(20).map { |o| "#{o.part} #{o.event_id}: #{o.note}" })
350
+ ClickwrapTasks.say(" skipped because something changed since the plan was reviewed:")
351
+ ClickwrapTasks.list(result.skipped_changed.first(20).map { |o| "#{o.part} #{o.event_id}: #{o.note}" })
352
+ ClickwrapTasks.say(" errors:")
353
+ ClickwrapTasks.list(result.errors.first(20).map { |o| "#{o.part} #{o.event_id}: #{o.note}" })
354
+ end
355
+
356
+ exit(1) unless result.clean?
357
+ end
358
+ end
359
+
360
+ namespace :holds do
361
+ desc "List legal holds in effect and the ones past their review date"
362
+ task review: :environment do
363
+ now = Clickwrap.now
364
+ in_effect = Clickwrap::LegalHold.in_effect.order(:review_at).to_a
365
+ due = in_effect.select { |hold| hold.review_at <= now }
366
+
367
+ ClickwrapTasks.heading("Legal holds")
368
+ ClickwrapTasks.say(" in effect: #{in_effect.length}")
369
+ ClickwrapTasks.say(" past review date: #{due.length}")
370
+ ClickwrapTasks.say
371
+ ClickwrapTasks.say(" past their review date:")
372
+ ClickwrapTasks.list(
373
+ due.map do |hold|
374
+ "#{hold.hold_scope} #{hold.event_id || hold.actor_reference || hold.policy_key} — " \
375
+ "review due #{hold.review_at}, placed #{hold.placed_at} by #{hold.placed_by_reference}: #{hold.reason}"
376
+ end
377
+ )
378
+ ClickwrapTasks.say
379
+ ClickwrapTasks.say("A hold pauses scheduled disposition. One nobody revisits is how everything")
380
+ ClickwrapTasks.say("ends up kept forever, which is why every hold carries a review date and an owner.")
381
+ end
382
+ end
383
+
384
+ namespace :privacy do
385
+ desc "Describe what this application is configured to record, and why it says it does"
386
+ task inventory: :environment do
387
+ inventory = Clickwrap::Privacy.inventory
388
+
389
+ if ClickwrapTasks.json?
390
+ ClickwrapTasks.dump(inventory)
391
+ else
392
+ ClickwrapTasks.heading("Request-evidence inventory")
393
+ ClickwrapTasks.say(" records anything by default: " \
394
+ "#{inventory["defaults"]["records_any_request_evidence_by_default"]}")
395
+ ClickwrapTasks.say(" unresolved host events: " \
396
+ "#{inventory["unresolved_host_events"].join(", ").presence || "(none)"}")
397
+
398
+ recording, quiet = inventory["policies"].partition { |policy| policy["records_any_request_evidence"] }
399
+
400
+ ClickwrapTasks.say(" policies recording no request evidence: " \
401
+ "#{quiet.map { |policy| policy["policy"] }.join(", ").presence || "(none)"}")
402
+
403
+ recording.each do |policy|
404
+ ClickwrapTasks.say
405
+ ClickwrapTasks.say(" #{policy["policy"]} (retention class #{policy["retention_class"]})")
406
+ policy["fields"].each do |field, details|
407
+ next unless details["recorded"]
408
+
409
+ ClickwrapTasks.say(" #{field}: #{details["because"]}")
410
+ ClickwrapTasks.say(" legal basis reference: " \
411
+ "#{details["legal_basis_reference"] || "(none supplied)"}")
412
+ ClickwrapTasks.say(" encrypted: #{details["encrypted"]}, " \
413
+ "delete after: #{details["delete_after_seconds"] || details["retain_until_rule"]}")
414
+ ClickwrapTasks.say(" fields: #{details["fields"].join(", ")}") if details["fields"]
415
+ end
416
+ ClickwrapTasks.say(" review on: " \
417
+ "#{policy["review_request_evidence_configuration_on"] || "(no date set)"}")
418
+ end
419
+
420
+ ClickwrapTasks.say
421
+ ClickwrapTasks.say("This describes a configuration. Describing a configuration is not the same as")
422
+ ClickwrapTasks.say("the configuration being lawful, necessary, or proportionate.")
423
+ end
424
+ end
425
+
426
+ desc "Export every receipt recorded for one actor (ACTOR=gid://my-app/User/123)"
427
+ task export: :environment do
428
+ actor = ClickwrapTasks.env!("ACTOR", "bin/rails clickwrap:privacy:export ACTOR=gid://my-app/User/123")
429
+
430
+ ClickwrapTasks.dump(
431
+ Clickwrap::Privacy.export_for(
432
+ actor,
433
+ requested_by: ClickwrapTasks.env("REQUESTED_BY"),
434
+ because: ClickwrapTasks.env("BECAUSE"),
435
+ include_ip_address: ClickwrapTasks.flag?("INCLUDE_IP_ADDRESS"),
436
+ include_browser_user_agent: ClickwrapTasks.flag?("INCLUDE_BROWSER_USER_AGENT"),
437
+ include_ip_geolocation: ClickwrapTasks.flag?("INCLUDE_IP_GEOLOCATION")
438
+ )
439
+ )
440
+ end
441
+
442
+ namespace :disposition do
443
+ desc "Build a reviewable plan for one actor's evidence (ACTOR=gid://...). Deletes nothing."
444
+ task plan: :environment do
445
+ actor = ClickwrapTasks.env!(
446
+ "ACTOR",
447
+ "bin/rails clickwrap:privacy:disposition:plan ACTOR=gid://my-app/User/123 BECAUSE=\"DSAR-2026-41\""
448
+ )
449
+ because = ClickwrapTasks.env!("BECAUSE", "BECAUSE=\"Verified erasure request DSAR-2026-41\"")
450
+
451
+ plan = Clickwrap::Privacy.plan_disposition_for(
452
+ actor,
453
+ requested_by: ClickwrapTasks.env("BY"),
454
+ because: because
455
+ )
456
+ summary = plan.summary.to_h
457
+
458
+ ClickwrapTasks.heading("Actor disposition plan #{plan.id}")
459
+ ClickwrapTasks.say(" items: #{summary["due"]}")
460
+ ClickwrapTasks.say(" held by a legal hold: #{summary["held"]}")
461
+ ClickwrapTasks.say(" still within retention period: #{summary["still_within_retention_period"]}")
462
+ ClickwrapTasks.say(" by part: #{summary["by_part"].inspect}")
463
+ ClickwrapTasks.say
464
+ ClickwrapTasks.say("Nothing has been deleted. This plan does not decide whether an erasure request")
465
+ ClickwrapTasks.say("overrides a retention duty, a legal claim, or a hold — it shows what exists so")
466
+ ClickwrapTasks.say("the person who makes that decision can see it.")
467
+ end
468
+ end
469
+ end
470
+
471
+ desc "List external actions that are still pending or unknown"
472
+ task reconcile_external_actions: :environment do
473
+ unresolved = Clickwrap::ExternalAction.unresolved.order(:requested_at).to_a
474
+ stale = Clickwrap::ExternalAction.needing_reconciliation.count
475
+
476
+ ClickwrapTasks.heading("Unresolved external actions")
477
+ ClickwrapTasks.say(" pending or unknown: #{unresolved.length}")
478
+ ClickwrapTasks.say(" older than 15 minutes: #{stale}")
479
+ ClickwrapTasks.say
480
+ ClickwrapTasks.list(
481
+ unresolved.first(50).map do |action|
482
+ "#{action.state} #{action.policy_key} #{action.idempotency_key} " \
483
+ "(requested #{action.requested_at}, #{action.attempt_count} attempts) provider=#{action.provider_name}"
484
+ end
485
+ )
486
+ ClickwrapTasks.say
487
+ ClickwrapTasks.say("Clickwrap will not guess an outcome for any of these. `unknown` means the request")
488
+ ClickwrapTasks.say("may or may not have been carried out, and treating that as a failure is how a")
489
+ ClickwrapTasks.say("second debit happens. Ask the provider, then record what it says:")
490
+ ClickwrapTasks.say(" action.record_provider_success_and_consume!(receipt)")
491
+ ClickwrapTasks.say(" action.record_provider_failure!(reason: \"...\")")
492
+ ClickwrapTasks.say(" action.record_provider_outcome_unknown!(reason: \"...\")")
493
+ end
494
+ end
495
+
496
+ namespace :clickwrap do
497
+ namespace :import do
498
+ namespace :fine_print do
499
+ desc "Preview what a FinePrint import would record, without writing anything"
500
+ task plan: :environment do
501
+ report = Clickwrap::Import::FinePrint.plan
502
+
503
+ ClickwrapTasks.heading("FinePrint import plan")
504
+ ClickwrapTasks.say(report.respond_to?(:message) ? report.message : report.to_s)
505
+ ClickwrapTasks.say
506
+ ClickwrapTasks.say("Nothing has been written. Fields FinePrint never recorded — the presentation")
507
+ ClickwrapTasks.say("manifest, the IP address, the call to action, the protected action — stay")
508
+ ClickwrapTasks.say("unknown in the imported events. Historical weakness stays visible rather than")
509
+ ClickwrapTasks.say("being laundered into modern certainty.")
510
+ end
511
+ end
512
+
513
+ desc "Import FinePrint contract versions and signatures as imported_legacy events"
514
+ task fine_print: :environment do
515
+ report = Clickwrap::Import::FinePrint.import!
516
+
517
+ ClickwrapTasks.heading("FinePrint import")
518
+ ClickwrapTasks.say(report.respond_to?(:message) ? report.message : report.to_s)
519
+ end
520
+ end
521
+ end
522
+
523
+ # --- Publishing rides `db:prepare` --------------------------------------------
524
+ #
525
+ # Deploys that migrate but never publish are the most common way a Clickwrap
526
+ # installation breaks: signup presentations refuse unpublished documents (the
527
+ # safe failure), so a forgotten `clickwrap:publish` becomes "nobody can sign
528
+ # up" some time after the deploy looked green. Enhancing `db:prepare` removes
529
+ # the step instead of documenting it — by the time the server takes traffic,
530
+ # every declared document version has an immutable snapshot.
531
+ #
532
+ # The guards, in order: the host can opt out
533
+ # (`config.publish_documents_after_database_preparation = false`); an
534
+ # installation with no declared documents stays silent; and an installation
535
+ # whose clickwrap tables do not exist yet (the generator ran, the migration
536
+ # has not) is skipped with a sentence rather than crashed. A real publish
537
+ # refusal — a reused version label over changed bytes — still fails the
538
+ # deploy loudly, which is strictly better than signups failing quietly later.
539
+ # No `desc` on purpose: this is db:prepare's follow-through, not a task
540
+ # anyone runs by hand — `clickwrap:publish` is the human-facing spelling.
541
+ # The `:environment` prerequisite matters: `db:prepare` itself runs on
542
+ # `:load_config` alone, and the declared documents and any host opt-out only
543
+ # exist once the application has actually booted.
544
+ namespace :clickwrap do
545
+ task publish_after_database_preparation: :environment do
546
+ if Clickwrap.config.publish_documents_after_database_preparation && Clickwrap.documents.any?
547
+ tables_ready =
548
+ begin
549
+ Clickwrap::Document.table_exists?
550
+ rescue StandardError
551
+ false
552
+ end
553
+
554
+ if tables_ready
555
+ Rake::Task["clickwrap:publish"].invoke
556
+ else
557
+ ClickwrapTasks.say("Clickwrap documents were not published: the clickwrap tables do " \
558
+ "not exist in this database yet. Run the clickwrap migration, then " \
559
+ "`bin/rails clickwrap:publish`.")
560
+ end
561
+ end
562
+ end
563
+ end
564
+
565
+ if Rake::Task.task_defined?("db:prepare")
566
+ Rake::Task["db:prepare"].enhance do
567
+ Rake::Task["clickwrap:publish_after_database_preparation"].invoke
568
+ end
569
+ end