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,502 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module Retention
5
+ # Builds a reviewable disposition plan. Deletes nothing.
6
+ #
7
+ # plan = Clickwrap::Retention::Planner.new(created_by: current_operator).call
8
+ # plan.summary # => counts by policy, by part, plus held and unresolved
9
+ # plan.id # => the id `clickwrap:retention:apply PLAN=...` needs
10
+ #
11
+ # Disposition is always two steps here: plan, review, then apply. This is
12
+ # the first step, and it is deliberately read-only — an operator can run it
13
+ # on a Friday afternoon without consequence, and the numbers it reports are
14
+ # the numbers the applier will re-derive.
15
+ #
16
+ # Three properties of the report matter more than the totals:
17
+ #
18
+ # * Held records are counted SEPARATELY rather than silently dropped, so
19
+ # an operator who expected 4,000 items and sees 12 can tell that a legal
20
+ # hold is working rather than that the query is broken.
21
+ #
22
+ # * A host-event rule that resolves to nil is UNRESOLVED, never due. "Five
23
+ # years, or three years after this contract is liquidated, whichever is
24
+ # later" has not started its clock until liquidation happens, and a job
25
+ # that treats "we cannot say yet" as "delete it now" destroys regulated
26
+ # evidence early. That failure mode is specific, foreseeable, and the
27
+ # reason this class reports a third category instead of two.
28
+ #
29
+ # * Nothing here decides a retention period. The host and its counsel
30
+ # chose the rules; this reads them back and says what they currently
31
+ # imply.
32
+ class Planner
33
+ KIND = "retention"
34
+
35
+ # Parts of the evidence a plan can cover. The first four are the parts of
36
+ # an event, named exactly as the retention DSL names them; the fifth is a
37
+ # persisted presentation, which is not evidence of an act and ages out on
38
+ # its own schedule.
39
+ PARTS = (RetentionClass::PARTS + %i[presentation]).freeze
40
+ ANNEX_PARTS = %i[ip_address browser_user_agent ip_geolocation].freeze
41
+
42
+ # How many held and unresolved examples travel in the summary. The counts
43
+ # are always complete; the examples exist so an operator can see what kind
44
+ # of thing is being held without opening a console, and are capped so a
45
+ # plan row stays a plan row rather than a data export.
46
+ EXAMPLE_LIMIT = 25
47
+
48
+ # What a rule currently says about one part, with "we cannot say yet"
49
+ # kept as a first-class answer rather than folded into a date.
50
+ Eligibility = Data.define(:eligible_at, :rule, :unresolved_reason) do
51
+ def initialize(eligible_at: nil, rule: nil, unresolved_reason: nil)
52
+ super
53
+ end
54
+
55
+ def resolved? = !eligible_at.nil?
56
+ def unresolved? = eligible_at.nil?
57
+ def due?(at) = resolved? && eligible_at <= at
58
+ end
59
+
60
+ # One line of a plan. `eligibility` records which question the applier
61
+ # must re-ask: a retention plan re-checks whether the rule still says the
62
+ # item is due, while an actor-request plan re-checks that the item still
63
+ # exists and is not held, because whether an erasure request outranks a
64
+ # retention duty is a decision a human made, not one this gem can make.
65
+ Item = Data.define(:part, :status, :event_id, :record_id, :policy_key, :actor_reference,
66
+ :retention_class_key, :eligible_at, :rule, :detail, :eligibility) do
67
+ def initialize(part:, status:, event_id: nil, record_id: nil, policy_key: nil,
68
+ actor_reference: nil, retention_class_key: nil, eligible_at: nil,
69
+ rule: nil, detail: nil, eligibility: "retention")
70
+ super
71
+ end
72
+
73
+ def to_plan_entry
74
+ {
75
+ "part" => part.to_s,
76
+ "status" => status.to_s,
77
+ "event_id" => event_id,
78
+ "record_id" => record_id&.to_s,
79
+ "policy_key" => policy_key,
80
+ "actor_reference" => actor_reference,
81
+ "retention_class" => retention_class_key,
82
+ "eligible_at" => eligible_at && Receipt.format_time(eligible_at),
83
+ "rule" => rule,
84
+ "detail" => detail,
85
+ "eligibility" => eligibility
86
+ }.compact
87
+ end
88
+
89
+ def self.from_plan_entry(entry)
90
+ entry = entry.to_h
91
+ new(
92
+ part: entry["part"].to_s.to_sym,
93
+ status: entry["status"].to_s.to_sym,
94
+ event_id: entry["event_id"],
95
+ record_id: entry["record_id"],
96
+ policy_key: entry["policy_key"],
97
+ actor_reference: entry["actor_reference"],
98
+ retention_class_key: entry["retention_class"],
99
+ rule: entry["rule"],
100
+ detail: entry["detail"],
101
+ eligibility: entry["eligibility"] || "retention"
102
+ )
103
+ end
104
+ end
105
+
106
+ class << self
107
+ # What the core event's rule currently says. Shared with the applier so
108
+ # planning and applying can never disagree about what "due" means.
109
+ #
110
+ # The order matters. A schedule computed at capture (`retain_core_event_until`)
111
+ # wins, because that is what the receipt already told the world. Only
112
+ # when there is none does this fall back to the rule the class names
113
+ # today. That fallback exists for records written by an older Clickwrap
114
+ # version; current writers freeze a schedule onto captures, imports,
115
+ # exemptions, and lifecycle events when each event is written.
116
+ def core_event_eligibility(event)
117
+ return Eligibility.new(eligible_at: event.retain_core_event_until, rule: "recorded_schedule") if
118
+ event.retain_core_event_until.present?
119
+
120
+ return resolve_host_event(event.retention_rule_name, event) if event.retention_rule_name.present?
121
+
122
+ retention_class = Clickwrap.retention_classes[event.retention_class_key.to_s]
123
+
124
+ if retention_class.nil?
125
+ return Eligibility.new(
126
+ rule: "retention_class:#{event.retention_class_key}",
127
+ unresolved_reason: "Retention class #{event.retention_class_key.inspect} is no longer " \
128
+ "defined, so nothing can say when this event is due."
129
+ )
130
+ end
131
+
132
+ rule = retention_class.rule_for(:core_event)
133
+ return Eligibility.new(rule: nil, unresolved_reason: "No core-event rule is defined.") if rule.nil?
134
+ return resolve_host_event(rule.host_event_name, event) if rule.host_event?
135
+
136
+ Eligibility.new(eligible_at: event.recorded_at_by_server + rule.duration,
137
+ rule: "duration:#{rule.duration.to_i}s")
138
+ end
139
+
140
+ # What one annex field's rule currently says. The row carries its own
141
+ # schedule or its own named rule, recorded when the value was captured,
142
+ # so this reads the row rather than today's policy: the person whose IP
143
+ # address it is was told the period that applied then.
144
+ def annex_eligibility(annex, part)
145
+ delete_after = annex.public_send(:"#{part}_delete_after")
146
+ return Eligibility.new(eligible_at: delete_after, rule: "recorded_schedule") if delete_after.present?
147
+
148
+ rule_name = annex.public_send(:"#{part}_retain_until_rule")
149
+ return resolve_host_event(rule_name, annex.event) if rule_name.present?
150
+
151
+ Eligibility.new(
152
+ rule: nil,
153
+ unresolved_reason: "No deletion rule is recorded for #{part} on this row."
154
+ )
155
+ end
156
+
157
+ # A host calculation may legitimately return nil ("the triggering event
158
+ # has not happened"), may be unregistered, and may raise — a retention
159
+ # calculation runs the host's own domain code. All three are reported as
160
+ # unresolved with the reason attached, because a disposition run that
161
+ # crashes on one row, or that guesses a date to keep going, is worse
162
+ # than one that says which rows it could not evaluate.
163
+ def resolve_host_event(name, event)
164
+ return Eligibility.new(rule: nil, unresolved_reason: "No rule name recorded.") if name.blank?
165
+
166
+ rule = "host_event:#{name}"
167
+ resolved = Clickwrap.configuration.resolve_retention_time(name, event)
168
+
169
+ if resolved.nil?
170
+ return Eligibility.new(
171
+ rule: rule,
172
+ unresolved_reason: "The host calculation #{name} has not resolved yet, so the " \
173
+ "triggering event has not happened and the clock has not started."
174
+ )
175
+ end
176
+
177
+ Eligibility.new(eligible_at: resolved, rule: rule)
178
+ rescue StandardError => error
179
+ Eligibility.new(
180
+ rule: "host_event:#{name}",
181
+ unresolved_reason: "The host calculation #{name} could not be evaluated (#{error.class})."
182
+ )
183
+ end
184
+ end
185
+
186
+ def initialize(at: Clickwrap.now, policy_key: nil, actor_reference: nil, created_by: nil, because: nil)
187
+ @at = at
188
+ @policy_key = policy_key&.to_s
189
+ @actor_reference = reference_for(actor_reference)
190
+ @created_by = created_by
191
+ @because = because
192
+ @items = []
193
+ end
194
+
195
+ attr_reader :at, :policy_key, :actor_reference, :created_by, :because, :items
196
+
197
+ # Builds and persists the plan. Nothing is deleted, nothing is marked, and
198
+ # no state changes anywhere else.
199
+ def call
200
+ SchemaRequirements.require!(:retention_ops)
201
+
202
+ @items = []
203
+
204
+ plan_core_events
205
+ plan_request_evidence
206
+ plan_presentations
207
+
208
+ DispositionPlan.create!(
209
+ kind: KIND,
210
+ disposition_scope: scope_document,
211
+ summary: summary_document,
212
+ item_count: due_items.length,
213
+ created_by_reference: reference_for(created_by),
214
+ reason: because
215
+ )
216
+ end
217
+
218
+ def due_items = items.select { |item| item.status == :due }
219
+ def held_items = items.select { |item| item.status == :held }
220
+ def unresolved_items = items.select { |item| item.status == :unresolved }
221
+
222
+ private
223
+
224
+ # --- The core event -------------------------------------------------------
225
+
226
+ # Three queries rather than one scan of the table.
227
+ #
228
+ # The first uses the schedule written at capture, which is indexed. The
229
+ # second catches rows that never got one — an appended lifecycle event has
230
+ # its class but no computed date — by pushing the class's duration into
231
+ # the WHERE clause instead of loading every event to subtract in Ruby. The
232
+ # third is the only unavoidable scan, and it is bounded to the classes
233
+ # that actually use a host-event rule, because those are precisely the
234
+ # rows whose answer cannot be computed in SQL.
235
+ def plan_core_events
236
+ seen = Set.new
237
+
238
+ core_event_scopes.each do |scope|
239
+ scope.find_each do |event|
240
+ next unless seen.add?(event.id)
241
+
242
+ record_core_event(event)
243
+ end
244
+ end
245
+ end
246
+
247
+ def core_event_scopes
248
+ scopes = [base_events.where.not(retain_core_event_until: nil).where(retain_core_event_until: ..at)]
249
+
250
+ Clickwrap.retention_classes.each do |retention_class|
251
+ rule = retention_class.rule_for(:core_event)
252
+ next if rule.nil?
253
+
254
+ unscheduled = base_events.where(retain_core_event_until: nil, retention_class_key: retention_class.key)
255
+
256
+ scopes << if rule.duration?
257
+ unscheduled.where(recorded_at_by_server: ...(at - rule.duration))
258
+ else
259
+ unscheduled
260
+ end
261
+ end
262
+
263
+ scopes << base_events.where(retain_core_event_until: nil).where.not(retention_rule_name: nil)
264
+ scopes
265
+ end
266
+
267
+ def record_core_event(event)
268
+ eligibility = self.class.core_event_eligibility(event)
269
+ return if eligibility.resolved? && !eligibility.due?(at)
270
+
271
+ record(
272
+ part: :core_event,
273
+ status: status_for(eligibility, held: hold_index.held?(event)),
274
+ event: event,
275
+ record_id: event.id,
276
+ eligibility: eligibility
277
+ )
278
+ end
279
+
280
+ # --- The optional request-evidence annex ----------------------------------
281
+
282
+ # Same reasoning as presentations: an installation can have the retention
283
+ # tables and not the annex, because they are separate capabilities behind
284
+ # separate flags. Nothing was ever recorded, so nothing is due.
285
+ def plan_request_evidence
286
+ return unless SchemaRequirements.available?(:request_evidence)
287
+
288
+ ANNEX_PARTS.each do |part|
289
+ seen = Set.new
290
+
291
+ annex_scopes(part).each do |scope|
292
+ scope.find_each do |annex|
293
+ next unless seen.add?(annex.id)
294
+ next if annex.event.nil?
295
+
296
+ record_annex_field(annex, part)
297
+ end
298
+ end
299
+ end
300
+ end
301
+
302
+ def annex_scopes(part)
303
+ [
304
+ base_annexes.public_send(:"with_#{part}_due", at),
305
+ base_annexes.where("#{part}_deleted_at": nil, "#{part}_delete_after": nil)
306
+ .where.not("#{part}_retain_until_rule": nil)
307
+ ]
308
+ end
309
+
310
+ def record_annex_field(annex, part)
311
+ eligibility = self.class.annex_eligibility(annex, part)
312
+ return if eligibility.resolved? && !eligibility.due?(at)
313
+
314
+ record(
315
+ part: part,
316
+ status: status_for(eligibility, held: hold_index.held?(annex.event)),
317
+ event: annex.event,
318
+ record_id: annex.id,
319
+ eligibility: eligibility
320
+ )
321
+ end
322
+
323
+ # --- Persisted presentations ---------------------------------------------
324
+
325
+ # A presentation row is not evidence of an act: it is the manifest the
326
+ # server offered. The one attached to a capture belongs to that event and
327
+ # is never touched here — `where.missing(:events)` is what keeps a
328
+ # retention run from deleting the manifest a receipt cites. What is left
329
+ # is the pre-submit rows a policy chose to retain, and expired offers
330
+ # nobody ever submitted.
331
+ # An installation can have the retention tables without the persisted
332
+ # presentations one — they are separate capabilities behind separate
333
+ # flags. Nothing was ever retained here, so there is nothing to dispose
334
+ # of, and asking the database would only raise about a table this
335
+ # installation deliberately does not have.
336
+ def plan_presentations
337
+ return unless SchemaRequirements.available?(:persisted_presentations)
338
+
339
+ seen = Set.new
340
+
341
+ presentation_scopes.each do |scope|
342
+ scope.where.missing(:events).find_each do |presentation|
343
+ next unless seen.add?(presentation.id)
344
+
345
+ record(
346
+ part: :presentation,
347
+ status: :due,
348
+ event: nil,
349
+ record_id: presentation.id,
350
+ policy_key_override: presentation.policy_key,
351
+ actor_reference_override: presentation.actor_reference,
352
+ eligibility: Eligibility.new(
353
+ eligible_at: presentation.retain_until || presentation.expires_at,
354
+ rule: presentation.retain_until ? "recorded_schedule" : "presentation_expiry"
355
+ )
356
+ )
357
+ end
358
+ end
359
+ end
360
+
361
+ def presentation_scopes
362
+ scope = Presentation.all
363
+ scope = scope.where(policy_key: policy_key) if policy_key
364
+ scope = scope.where(actor_reference: actor_reference) if actor_reference
365
+
366
+ [
367
+ scope.due_for_disposition(at),
368
+ scope.pending.where(retain_until: nil).expired_at(at)
369
+ ]
370
+ end
371
+
372
+ # --- Assembling the plan --------------------------------------------------
373
+
374
+ def base_events
375
+ # A disposition event is the retained tombstone that makes an earlier
376
+ # deletion explainable. Planning it for disposition would destroy that
377
+ # explanation and produce an endless disposition-of-disposition chain.
378
+ scope = Event.not_disposed.where.not(event_type: "disposition")
379
+ scope = scope.for_policy(policy_key) if policy_key
380
+ scope = scope.for_actor(actor_reference) if actor_reference
381
+ scope
382
+ end
383
+
384
+ # `preload` rather than `includes`, because this relation also joins the
385
+ # same table to filter on it, and a single query doing both would have to
386
+ # disambiguate the alias. Two queries and no ambiguity is the better
387
+ # trade for a batch job.
388
+ def base_annexes
389
+ scope = RequestEvidence.preload(:event).joins(:event)
390
+ scope = scope.where(clickwrap_events: { policy_key: policy_key }) if policy_key
391
+ scope = scope.where(clickwrap_events: { actor_reference: actor_reference }) if actor_reference
392
+ scope
393
+ end
394
+
395
+ def hold_index
396
+ @hold_index ||= HoldIndex.load
397
+ end
398
+
399
+ def status_for(eligibility, held:)
400
+ return :unresolved if eligibility.unresolved?
401
+ return :held if held
402
+
403
+ :due
404
+ end
405
+
406
+ def record(part:, status:, event:, record_id:, eligibility:,
407
+ policy_key_override: nil, actor_reference_override: nil)
408
+ @items << Item.new(
409
+ part: part,
410
+ status: status,
411
+ event_id: event&.id,
412
+ record_id: record_id,
413
+ policy_key: policy_key_override || event&.policy_key,
414
+ actor_reference: actor_reference_override || event&.actor_reference,
415
+ retention_class_key: event&.retention_class_key,
416
+ eligible_at: eligibility.eligible_at,
417
+ rule: eligibility.rule,
418
+ detail: eligibility.unresolved_reason
419
+ )
420
+ end
421
+
422
+ # The exact set an operator reviewed. The applier reads `items` back and
423
+ # re-checks every one of them; the plan is the record of what was agreed
424
+ # to, not an instruction the applier follows blindly.
425
+ def scope_document
426
+ {
427
+ "kind" => KIND,
428
+ "at" => Receipt.format_time(at),
429
+ "policy_key" => policy_key,
430
+ "actor_reference" => actor_reference,
431
+ "items" => due_items.map(&:to_plan_entry)
432
+ }.compact
433
+ end
434
+
435
+ def summary_document
436
+ {
437
+ "generated_at" => Receipt.format_time(Clickwrap.now),
438
+ "evaluated_at" => Receipt.format_time(at),
439
+ "due" => due_items.length,
440
+ "held" => held_items.length,
441
+ "unresolved" => unresolved_items.length,
442
+ "by_part" => counts_by(&:part),
443
+ "by_policy" => counts_by { |item| item.policy_key || "(none)" },
444
+ "held_examples" => held_items.first(EXAMPLE_LIMIT).map(&:to_plan_entry),
445
+ "unresolved_examples" => unresolved_items.first(EXAMPLE_LIMIT).map(&:to_plan_entry),
446
+ "means" => "What each rule currently says is due, held, or not yet resolvable. Nothing " \
447
+ "has been deleted, and Clickwrap did not choose any of these periods."
448
+ }
449
+ end
450
+
451
+ # Counts are always broken out three ways, and a zero is printed rather
452
+ # than omitted: "0 held" and "held is missing from the report" are
453
+ # different statements, and only one of them is reassuring.
454
+ def counts_by
455
+ items.group_by { |item| yield(item).to_s }.transform_values do |group|
456
+ {
457
+ "due" => group.count { |item| item.status == :due },
458
+ "held" => group.count { |item| item.status == :held },
459
+ "unresolved" => group.count { |item| item.status == :unresolved }
460
+ }
461
+ end
462
+ end
463
+
464
+ def reference_for(actor)
465
+ Reference.actor(actor)
466
+ end
467
+
468
+ # Every legal hold currently in effect, loaded once.
469
+ #
470
+ # Holds are few and disposition runs touch many rows, so the alternative —
471
+ # three existence queries per candidate — would make the safety check the
472
+ # expensive part of the job, and an expensive safety check is one someone
473
+ # eventually turns off.
474
+ class HoldIndex
475
+ def self.load
476
+ holds = LegalHold.in_effect.to_a
477
+
478
+ new(
479
+ event_ids: holds.select { |hold| hold.hold_scope == "event" }.map(&:event_id).compact.to_set,
480
+ actor_references: holds.select { |hold| hold.hold_scope == "actor" }.map(&:actor_reference).compact.to_set,
481
+ policy_keys: holds.select { |hold| hold.hold_scope == "policy" }.map(&:policy_key).compact.to_set
482
+ )
483
+ end
484
+
485
+ def initialize(event_ids:, actor_references:, policy_keys:)
486
+ @event_ids = event_ids
487
+ @actor_references = actor_references
488
+ @policy_keys = policy_keys
489
+ end
490
+
491
+ def held?(event)
492
+ return false if event.nil?
493
+
494
+ event.on_legal_hold? ||
495
+ @event_ids.include?(event.id) ||
496
+ @actor_references.include?(event.actor_reference) ||
497
+ @policy_keys.include?(event.policy_key)
498
+ end
499
+ end
500
+ end
501
+ end
502
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # An application-defined retention class: how long each part of an event is
5
+ # kept, and what triggers the clock.
6
+ #
7
+ # Clickwrap.retention :ordinary_agreement_evidence do
8
+ # retain_core_event_for 6.years
9
+ # delete_recorded_ip_address_after 90.days
10
+ # delete_recorded_browser_user_agent_after 90.days
11
+ # delete_recorded_ip_geolocation_after 90.days
12
+ # end
13
+ #
14
+ # Clickwrap does not choose these periods and cannot tell you whether yours
15
+ # are right. What it does is make a reviewed decision executable and
16
+ # auditable, keep the core event's schedule separate from the optional
17
+ # personal request evidence, and delegate event-based or "later of" rules to
18
+ # a named host calculation. The host owns that calculation because a fixed
19
+ # duration cannot express "five years, or three years after this contract is
20
+ # liquidated, whichever is later" without application domain state.
21
+ class RetentionClass
22
+ PARTS = %i[core_event ip_address browser_user_agent ip_geolocation].freeze
23
+
24
+ # A rule is either a duration from the event's server-recorded time, or the
25
+ # name of a host-registered calculation that may depend on domain state and
26
+ # may not be resolvable yet.
27
+ Rule = Data.define(:part, :duration, :host_event_name) do
28
+ def initialize(part:, duration: nil, host_event_name: nil)
29
+ super
30
+ end
31
+
32
+ def duration? = !duration.nil?
33
+ def host_event? = !host_event_name.nil?
34
+
35
+ def to_snapshot
36
+ { "duration_seconds" => duration&.to_i, "host_event" => host_event_name&.to_s }.compact
37
+ end
38
+ end
39
+
40
+ attr_reader :key, :rules
41
+
42
+ def initialize(key:, rules:)
43
+ @key = key.to_s
44
+ @rules = rules.freeze
45
+
46
+ validate!
47
+ freeze
48
+ end
49
+
50
+ def rule_for(part) = rules[part.to_sym]
51
+
52
+ def to_snapshot
53
+ {
54
+ "key" => key,
55
+ "rules" => rules.to_h { |part, rule| [part.to_s, rule.to_snapshot] }
56
+ }
57
+ end
58
+
59
+ private
60
+
61
+ def validate!
62
+ unknown = rules.keys - PARTS
63
+ unless unknown.empty?
64
+ raise DefinitionError,
65
+ "Retention class #{key} declares rules for #{unknown.join(", ")}, which are not " \
66
+ "parts of an event. Choose from: #{PARTS.join(", ")}."
67
+ end
68
+
69
+ rules.each_value do |rule|
70
+ if rule.duration? == rule.host_event?
71
+ raise DefinitionError,
72
+ "Retention class #{key} must give #{rule.part} exactly one schedule: a " \
73
+ "duration or a named host calculation, not both or neither."
74
+ end
75
+
76
+ if rule.host_event? && rule.host_event_name.to_s.strip.empty?
77
+ raise DefinitionError,
78
+ "Retention class #{key} gives #{rule.part} a blank host calculation name. " \
79
+ "Name the calculation registered with `config.calculate_retention_time_for`."
80
+ end
81
+
82
+ next unless rule.duration? && rule.duration.to_i <= 0
83
+
84
+ raise DefinitionError,
85
+ "Retention class #{key} keeps #{rule.part} for #{rule.duration.inspect}, which is " \
86
+ "not a period."
87
+ end
88
+
89
+ return if rules.key?(:core_event)
90
+
91
+ raise DefinitionError,
92
+ "Retention class #{key} never says how long to keep the core event. Use " \
93
+ "`retain_core_event_for 6.years` or `retain_core_event_until :your_host_event`. " \
94
+ "Clickwrap has no forever default, and it will not pick a period for you."
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # Detects scaffolding language that must never be mistaken for a reviewed
5
+ # operational reason. This deliberately does not reject the lowercase word
6
+ # "todo": it is an ordinary Spanish word. It catches conventional developer
7
+ # markers and generated English placeholders instead.
8
+ module ReviewedText
9
+ PLACEHOLDER_PATTERNS = [
10
+ /\A(?:TODO|TBD|FIXME)(?:\b|:)/,
11
+ /replace (?:this|with|me)/i,
12
+ /your reviewed (?:purpose|reason|decision)/i,
13
+ /placeholder/i,
14
+ /\A\.{3}\z/
15
+ ].freeze
16
+
17
+ module_function
18
+
19
+ def placeholder?(value)
20
+ text = value.to_s.strip
21
+ text.present? && PLACEHOLDER_PATTERNS.any? { |pattern| pattern.match?(text) }
22
+ end
23
+
24
+ def present_and_reviewed?(value)
25
+ value.to_s.strip.present? && !placeholder?(value)
26
+ end
27
+ end
28
+ end