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,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # What one policy is allowed to record about the HTTP request that carried a
5
+ # capture.
6
+ #
7
+ # Everything here is off unless a policy or the initializer names it. That is
8
+ # not squeamishness about useful data: it is that high-quality evidence is
9
+ # purpose-specific. An IP address is personal data, and keeping it on your own
10
+ # infrastructure does not remove the duty to have a reason for it, protect it,
11
+ # and stop keeping it eventually. So each field is enabled by name, with a
12
+ # plain-English purpose and a retention decision attached, and the policy that
13
+ # enables it is the server's, never the browser's.
14
+ #
15
+ # None of these fields is identity or physical location. An IP address is a
16
+ # network observation. IP geolocation is a provider's estimate about that
17
+ # address. The raw User-Agent header is whatever the client chose to send.
18
+ class RequestEvidencePolicy
19
+ FIELD_CATEGORIES = %i[ip_address browser_user_agent ip_geolocation].freeze
20
+
21
+ # One category's settings: whether to record it, why, how long, and what
22
+ # happens when it cannot be resolved.
23
+ Setting = Data.define(
24
+ :record, :encrypted, :delete_after, :retain_until, :fail_if_unavailable,
25
+ :because, :legal_basis_reference, :data_protection_impact_assessment_reference
26
+ ) do
27
+ def initialize(record: false, encrypted: nil, delete_after: nil, retain_until: nil,
28
+ fail_if_unavailable: false, because: nil, legal_basis_reference: nil,
29
+ data_protection_impact_assessment_reference: nil)
30
+ super
31
+ end
32
+
33
+ def record? = record == true
34
+ def encrypted? = encrypted == true
35
+ def fail_if_unavailable? = fail_if_unavailable == true
36
+
37
+ def to_snapshot
38
+ {
39
+ "record" => record?,
40
+ "encrypted" => encrypted?,
41
+ "delete_after_seconds" => delete_after&.to_i,
42
+ "retain_until_rule" => retain_until&.to_s,
43
+ "fail_if_unavailable" => fail_if_unavailable?,
44
+ "because" => because,
45
+ "legal_basis_reference" => legal_basis_reference,
46
+ "data_protection_impact_assessment_reference" => data_protection_impact_assessment_reference
47
+ }.compact
48
+ end
49
+ end
50
+
51
+ NOT_RECORDED = Setting.new(record: false).freeze
52
+
53
+ attr_reader :ip_address, :browser_user_agent, :ip_geolocation,
54
+ :ip_geolocation_fields, :ip_geolocation_resolver_name,
55
+ :review_configuration_on, :policy_key, :retention_class_key,
56
+ :trusted_proxy_configuration_digest
57
+
58
+ def initialize(policy_key:, retention_class_key: nil, ip_address: nil,
59
+ browser_user_agent: nil, ip_geolocation: nil,
60
+ ip_geolocation_fields: {}, ip_geolocation_resolver_name: nil,
61
+ trusted_proxy_configuration_digest: nil, review_configuration_on: nil)
62
+ @policy_key = policy_key
63
+ @retention_class_key = retention_class_key&.to_s
64
+ @ip_address = normalized_setting(:ip_address, ip_address || NOT_RECORDED)
65
+ @browser_user_agent = normalized_setting(:browser_user_agent, browser_user_agent || NOT_RECORDED)
66
+ @ip_geolocation = normalized_setting(:ip_geolocation, ip_geolocation || NOT_RECORDED)
67
+ @ip_geolocation_fields = normalize_geolocation_fields(ip_geolocation_fields)
68
+ @ip_geolocation_resolver_name = ip_geolocation_resolver_name&.to_sym
69
+ @trusted_proxy_configuration_digest = trusted_proxy_configuration_digest&.to_s
70
+ @review_configuration_on = review_configuration_on
71
+
72
+ validate!
73
+ freeze
74
+ end
75
+
76
+ def records_ip_address? = ip_address.record?
77
+ def records_browser_user_agent? = browser_user_agent.record?
78
+ def records_ip_geolocation? = ip_geolocation.record?
79
+
80
+ def records_ip_geolocation_field?(field)
81
+ ip_geolocation_fields.fetch(field.to_s, false)
82
+ end
83
+
84
+ def enabled_ip_geolocation_fields
85
+ ip_geolocation_fields.select { |_, enabled| enabled }.keys
86
+ end
87
+
88
+ def records_anything? = records_ip_address? || records_browser_user_agent? || records_ip_geolocation?
89
+
90
+ def setting_for(category)
91
+ case category.to_sym
92
+ when :ip_address then ip_address
93
+ when :browser_user_agent then browser_user_agent
94
+ when :ip_geolocation then ip_geolocation
95
+ else raise ArgumentError, "Unknown request-evidence category #{category.inspect}"
96
+ end
97
+ end
98
+
99
+ # The exact field allowlist stored beside any recorded request evidence, so
100
+ # a reader years later can tell what the server was authorized to keep —
101
+ # not merely what happens to be present.
102
+ def authorized_fields_manifest
103
+ {
104
+ "ip_address" => records_ip_address?,
105
+ "browser_user_agent" => records_browser_user_agent?,
106
+ "ip_geolocation" => ip_geolocation_fields.dup
107
+ }
108
+ end
109
+
110
+ def to_snapshot
111
+ {
112
+ "ip_address" => ip_address.to_snapshot,
113
+ "browser_user_agent" => browser_user_agent.to_snapshot,
114
+ "ip_geolocation" => ip_geolocation.to_snapshot.merge(
115
+ "fields" => ip_geolocation_fields,
116
+ "resolver" => ip_geolocation_resolver_name&.to_s
117
+ ).compact,
118
+ "trusted_proxy_configuration_digest" => trusted_proxy_configuration_digest,
119
+ "review_configuration_on" => review_configuration_on&.to_s
120
+ }.compact
121
+ end
122
+
123
+ private
124
+
125
+ def normalize_geolocation_fields(declaration)
126
+ Vocabulary::IP_GEOLOCATION_DATA_FIELDS.to_h do |field|
127
+ [field, declaration.transform_keys(&:to_s).fetch(field, false) == true]
128
+ end.freeze
129
+ end
130
+
131
+ def validate!
132
+ FIELD_CATEGORIES.each { |category| validate_category!(category) }
133
+ validate_geolocation_coherence!
134
+ validate_named_resolver!
135
+ validate_trusted_proxy_configuration_digest!
136
+ end
137
+
138
+ def normalized_setting(category, setting)
139
+ return setting unless setting.record?
140
+
141
+ configured = case category
142
+ when :ip_address then Clickwrap.config.encrypt_recorded_ip_addresses
143
+ when :browser_user_agent then Clickwrap.config.encrypt_recorded_browser_user_agents
144
+ else Clickwrap.config.encrypt_recorded_ip_geolocation
145
+ end
146
+
147
+ if !setting.encrypted.nil? && setting.encrypted != configured
148
+ raise DefinitionError,
149
+ "Policy #{policy_key} says `encrypted: #{setting.encrypted}` for #{category}, but " \
150
+ "the application-wide storage setting is #{configured}. Active Record encryption " \
151
+ "is configured per column, not per row, so Clickwrap refuses to record a policy " \
152
+ "snapshot that would misdescribe storage. Change the matching " \
153
+ "`config.encrypt_recorded_*` setting, or omit `encrypted:` to inherit it."
154
+ end
155
+
156
+ Setting.new(**setting.to_h, encrypted: configured)
157
+ end
158
+
159
+ def validate_named_resolver!
160
+ return unless records_ip_geolocation?
161
+
162
+ resolver = Clickwrap.config.ip_geolocation_resolver_for(ip_geolocation_resolver_name)
163
+ unless resolver
164
+ raise DefinitionError,
165
+ "Policy #{policy_key} records IP geolocation using resolver " \
166
+ "#{ip_geolocation_resolver_name.inspect}, but no resolver is registered under " \
167
+ "that name. Configure `config.ip_geolocation_resolver` for " \
168
+ "`:application_default`, or register the named resolver with " \
169
+ "`config.register_ip_geolocation_resolver`. Registered resolvers: " \
170
+ "#{Clickwrap.config.ip_geolocation_resolver_names.join(", ").presence || "(none)"}."
171
+ end
172
+
173
+ capabilities = Array(resolver.capabilities).map(&:to_s)
174
+ impossible = enabled_ip_geolocation_fields - capabilities
175
+ return if impossible.empty?
176
+
177
+ raise DefinitionError,
178
+ "Policy #{policy_key} asks IP-geolocation resolver " \
179
+ "#{ip_geolocation_resolver_name.inspect} for #{impossible.join(", ")}, but that " \
180
+ "resolver says it cannot supply #{impossible.many? ? "those fields" : "that field"}. " \
181
+ "Choose fields the resolver supports or configure a resolver whose `capabilities` " \
182
+ "include them."
183
+ rescue NotImplementedError => error
184
+ raise DefinitionError,
185
+ "The IP-geolocation resolver for policy #{policy_key} cannot describe its " \
186
+ "capabilities: #{error.message}"
187
+ end
188
+
189
+ def validate_category!(category)
190
+ setting = setting_for(category)
191
+ return unless setting.record?
192
+
193
+ if setting.because.to_s.strip.empty?
194
+ raise DefinitionError,
195
+ "Policy #{policy_key} records #{category} but gives no `because:`. Say in one " \
196
+ "plain sentence why this policy needs it right now. \"We might need it someday\" " \
197
+ "is not a purpose, and a privacy notice mentioning the field is not one either."
198
+ end
199
+
200
+ if ReviewedText.placeholder?(setting.because)
201
+ raise DefinitionError,
202
+ "Policy #{policy_key} records #{category}, but its `because:` is still " \
203
+ "scaffolding text (#{setting.because.inspect}). Replace it with the " \
204
+ "application's reviewed, present-tense reason; Clickwrap never treats a TODO " \
205
+ "as a data-collection purpose."
206
+ end
207
+
208
+ if setting.delete_after.nil? && setting.retain_until.nil? && retention_class_key.nil?
209
+ raise DefinitionError,
210
+ "Policy #{policy_key} records #{category} but never says when to delete it. " \
211
+ "Give it `delete_after:` with a duration, or `retain_until:` naming a host event " \
212
+ "rule, or attach a retention class with a rule for this category. Clickwrap has " \
213
+ "no keep-forever default."
214
+ end
215
+
216
+ return unless setting.delete_after && setting.delete_after.to_i <= 0
217
+
218
+ raise DefinitionError,
219
+ "Policy #{policy_key} sets `delete_after:` for #{category} to " \
220
+ "#{setting.delete_after.inspect}, which is not a period in the future."
221
+ end
222
+
223
+ def validate_geolocation_coherence!
224
+ enabled = enabled_ip_geolocation_fields
225
+
226
+ if ip_geolocation.record? && enabled.empty?
227
+ raise DefinitionError,
228
+ "Policy #{policy_key} calls `record_ip_geolocation` but enables no field. " \
229
+ "Name the fields you actually need, for example `country: true`."
230
+ end
231
+
232
+ if ip_geolocation.record? &&
233
+ records_ip_geolocation_field?(:latitude_and_longitude) &&
234
+ !records_ip_geolocation_field?(:accuracy_radius_in_kilometers)
235
+ raise DefinitionError,
236
+ "Policy #{policy_key} records IP-geolocation latitude and longitude but does not " \
237
+ "record `accuracy_radius_in_kilometers:`. Provider-derived coordinates without " \
238
+ "their uncertainty look more precise than they are. Set both fields to true, or " \
239
+ "turn latitude and longitude off. Clickwrap will not enable another field silently."
240
+ end
241
+
242
+ return if ip_geolocation.record? || enabled.empty?
243
+
244
+ raise DefinitionError,
245
+ "Policy #{policy_key} enables the IP-geolocation fields #{enabled.join(", ")} " \
246
+ "without recording IP geolocation."
247
+ end
248
+
249
+ def validate_trusted_proxy_configuration_digest!
250
+ return unless records_ip_address? || records_ip_geolocation?
251
+ return unless trusted_proxy_configuration_digest.to_s.strip.empty?
252
+
253
+ raise DefinitionError,
254
+ "Policy #{policy_key} records an IP address or derives IP geolocation, but " \
255
+ "`config.trusted_proxy_configuration_digest` is blank. Review the deployment's " \
256
+ "trusted-proxy topology, digest that reviewed configuration, and set the prefixed " \
257
+ "digest (for example `sha256:...`). This records which proxy decision produced the " \
258
+ "address; it does not claim that the decision was correct."
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module Retention
5
+ # Applies a reviewed disposition plan.
6
+ #
7
+ # result = Clickwrap::Retention::Applier.new(plan, applied_by: current_operator).call
8
+ # result.counts # => {"applied" => 812, "skipped_held" => 4, ...}
9
+ #
10
+ # The plan said what would happen. This re-asks every question before it
11
+ # does anything, because between the review and the run someone may have
12
+ # placed a legal hold, changed a retention class, released a hold, deleted a
13
+ # field by hand, or applied the plan already. When an item's answer has
14
+ # changed, that item stops and is reported — the run never deletes a broader
15
+ # set than the person who reviewed it agreed to, and never quietly deletes a
16
+ # narrower one either.
17
+ #
18
+ # Each item is its own transaction: the deletion and the event that
19
+ # documents it commit together, and a failure on item 900 does not undo the
20
+ # 899 dispositions that already succeeded and were already recorded.
21
+ class Applier
22
+ # One line of the report. The same shape for every outcome, so a task, a
23
+ # test, and a job all read it the same way, and `note` always says in a
24
+ # sentence why the line is where it is.
25
+ Outcome = Data.define(:part, :event_id, :record_id, :policy_key, :note) do
26
+ def initialize(part:, event_id: nil, record_id: nil, policy_key: nil, note: nil)
27
+ super
28
+ end
29
+
30
+ def to_report_entry
31
+ {
32
+ "part" => part.to_s,
33
+ "event_id" => event_id,
34
+ "record_id" => record_id&.to_s,
35
+ "policy_key" => policy_key,
36
+ "note" => note
37
+ }.compact
38
+ end
39
+ end
40
+
41
+ Result = Data.define(:applied, :skipped_held, :skipped_changed, :errors) do
42
+ def counts
43
+ {
44
+ "applied" => applied.length,
45
+ "skipped_held" => skipped_held.length,
46
+ "skipped_changed" => skipped_changed.length,
47
+ "errors" => errors.length
48
+ }
49
+ end
50
+
51
+ def to_h
52
+ {
53
+ "applied" => applied.map(&:to_report_entry),
54
+ "skipped_held" => skipped_held.map(&:to_report_entry),
55
+ "skipped_changed" => skipped_changed.map(&:to_report_entry),
56
+ "errors" => errors.map(&:to_report_entry),
57
+ "counts" => counts
58
+ }
59
+ end
60
+
61
+ def clean? = errors.empty?
62
+ end
63
+
64
+ def initialize(plan, applied_by:, recover_application_if_stale_for: nil,
65
+ because_recovery_is_needed: nil)
66
+ @plan = plan
67
+ @applied_by = applied_by
68
+ @recover_application_if_stale_for = recover_application_if_stale_for
69
+ @because_recovery_is_needed = because_recovery_is_needed
70
+ @at = Clickwrap.now
71
+ @applied = []
72
+ @skipped_held = []
73
+ @skipped_changed = []
74
+ @errors = []
75
+ end
76
+
77
+ attr_reader :plan, :applied_by, :at
78
+
79
+ def call
80
+ # First, before anything is read or deleted: is this plan still one
81
+ # somebody may act on? An expired, superseded, or already-applied plan
82
+ # names a set that nobody currently agrees to.
83
+ plan.claim_for_application!(
84
+ by_reference: reference_for(applied_by),
85
+ recover_if_stale_after: @recover_application_if_stale_for,
86
+ because_recovery_is_needed: @because_recovery_is_needed
87
+ )
88
+
89
+ plan_items.each { |item| apply_item(item) }
90
+
91
+ # The plan is single-use whether or not every item succeeded. A plan
92
+ # that could be run twice would be a plan whose second run nobody
93
+ # reviewed; the operator re-plans instead, and the fresh plan shows what
94
+ # is still outstanding.
95
+ result = Result.new(applied: @applied, skipped_held: @skipped_held,
96
+ skipped_changed: @skipped_changed, errors: @errors)
97
+ plan.finish_application!(outcome_summary: result.to_h, had_errors: @errors.any?)
98
+ result
99
+ end
100
+
101
+ private
102
+
103
+ def plan_items
104
+ Array(plan.disposition_scope.to_h["items"]).map { |entry| Planner::Item.from_plan_entry(entry) }
105
+ end
106
+
107
+ def apply_item(item)
108
+ case item.part
109
+ when :core_event then apply_core_event(item)
110
+ when :ip_address, :browser_user_agent, :ip_geolocation then apply_annex_field(item)
111
+ when :presentation then apply_presentation(item)
112
+ else
113
+ record_changed(item, "#{item.part} is not something this version of Clickwrap disposes of.")
114
+ end
115
+ rescue LegalHoldInEffect => error
116
+ # Belt and braces: the hold was already checked above, so reaching here
117
+ # means one was placed between the check and the write. The hold wins.
118
+ record_held(item, error.message)
119
+ rescue StandardError => error
120
+ @errors << outcome_for(item, "#{error.class}: #{error.message}")
121
+ end
122
+
123
+ # --- The core event -------------------------------------------------------
124
+
125
+ def apply_core_event(item)
126
+ event = Event.find_by(id: item.event_id)
127
+ return record_changed(item, "The event is no longer in the database.") if event.nil?
128
+ return record_changed(item, "The core event was already disposed of.") if event.disposed?
129
+ return record_held(item, hold_note(event)) if Disposition.legal_hold_in_effect?(event)
130
+
131
+ eligibility = Planner.core_event_eligibility(event)
132
+ return record_changed(item, not_due_note(eligibility)) unless still_disposable?(item, eligibility)
133
+
134
+ Disposition.dispose_core_event!(event, because: reason)
135
+ record_applied(item, "The core event was marked disposed of. The row and its history stay.")
136
+ end
137
+
138
+ # --- The optional request-evidence annex ----------------------------------
139
+
140
+ def apply_annex_field(item)
141
+ annex = RequestEvidence.find_by(id: item.record_id)
142
+ return record_changed(item, "The request-evidence row is no longer in the database.") if annex.nil?
143
+
144
+ event = annex.event
145
+ return record_changed(item, "The event this evidence belonged to is gone.") if event.nil?
146
+ return record_changed(item, "#{item.part} was already deleted.") if annex.deleted_for?(item.part)
147
+ return record_held(item, hold_note(event)) if Disposition.legal_hold_in_effect?(event)
148
+
149
+ eligibility = Planner.annex_eligibility(annex, item.part)
150
+ return record_changed(item, not_due_note(eligibility)) unless still_disposable?(item, eligibility)
151
+
152
+ deleted = Disposition.delete_field!(event, item.part, because: reason)
153
+ return record_changed(item, "Nothing was recorded for #{item.part}, so nothing was deleted.") if deleted.nil?
154
+
155
+ record_applied(item, "The recorded #{item.part} was deleted and the deletion was recorded.")
156
+ end
157
+
158
+ # --- Persisted presentations ---------------------------------------------
159
+
160
+ def apply_presentation(item)
161
+ presentation = Presentation.find_by(id: item.record_id)
162
+ return record_changed(item, "The presentation is no longer in the database.") if presentation.nil?
163
+
164
+ if presentation.events.exists?
165
+ return record_changed(item, "An event now cites this presentation, so it is part of the evidence.")
166
+ end
167
+
168
+ due_at = presentation.retain_until || presentation.expires_at
169
+ return record_changed(item, "This presentation is not past its retention date.") if due_at.nil? || due_at > at
170
+
171
+ ::ActiveRecord::Base.transaction { presentation.destroy! }
172
+ record_applied(item, "The unsubmitted presentation was deleted.")
173
+ end
174
+
175
+ # --- Re-checking ----------------------------------------------------------
176
+
177
+ # A retention item must still be due under the rule as it stands right
178
+ # now. An actor-request item is checked for holds and for still existing,
179
+ # but not for retention: whether an erasure request outweighs a retention
180
+ # duty is a decision a person made when they created and reviewed that
181
+ # plan, and re-litigating it here would either ignore their decision or
182
+ # pretend Clickwrap had made it.
183
+ def still_disposable?(item, eligibility)
184
+ return true if item.eligibility == "actor_request"
185
+
186
+ eligibility.due?(at)
187
+ end
188
+
189
+ def not_due_note(eligibility)
190
+ if eligibility.unresolved?
191
+ "The rule no longer resolves to a date: #{eligibility.unresolved_reason}"
192
+ else
193
+ "The rule now says this is not due until #{Receipt.format_time(eligibility.eligible_at)}."
194
+ end
195
+ end
196
+
197
+ def hold_note(event)
198
+ "A legal hold is in effect for event #{event.id}, so nothing was deleted."
199
+ end
200
+
201
+ # --- Recording ------------------------------------------------------------
202
+
203
+ def record_applied(item, note) = @applied << outcome_for(item, note)
204
+ def record_held(item, note) = @skipped_held << outcome_for(item, note)
205
+ def record_changed(item, note) = @skipped_changed << outcome_for(item, note)
206
+
207
+ def outcome_for(item, note)
208
+ Outcome.new(part: item.part, event_id: item.event_id, record_id: item.record_id,
209
+ policy_key: item.policy_key, note: note)
210
+ end
211
+
212
+ # The sentence that ends up on every disposition event this run appends.
213
+ # It names the plan, so the deletion and the review that authorized it can
214
+ # be read back together.
215
+ def reason
216
+ [sentence(plan.reason), "Applied disposition plan #{plan.id}."].compact.join(" ")
217
+ end
218
+
219
+ def sentence(text)
220
+ trimmed = text.to_s.strip
221
+ return nil if trimmed.empty?
222
+
223
+ trimmed.end_with?(".", "!", "?") ? trimmed : "#{trimmed}."
224
+ end
225
+
226
+ def reference_for(actor)
227
+ Reference.actor(actor)
228
+ end
229
+ end
230
+ end
231
+ end