clickwrap 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +650 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +245 -0
  7. data/app/controllers/clickwrap/application_controller.rb +79 -0
  8. data/app/controllers/clickwrap/captures_controller.rb +145 -0
  9. data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
  10. data/app/controllers/clickwrap/receipts_controller.rb +115 -0
  11. data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
  12. data/app/helpers/clickwrap/engine_helper.rb +97 -0
  13. data/app/views/clickwrap/captures/show.html.erb +34 -0
  14. data/app/views/clickwrap/receipts/index.html.erb +38 -0
  15. data/app/views/clickwrap/receipts/show.html.erb +91 -0
  16. data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
  17. data/app/views/clickwrap/shared/_fields.html.erb +100 -0
  18. data/app/views/clickwrap/shared/_statement.html.erb +105 -0
  19. data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
  20. data/config/locales/en.yml +160 -0
  21. data/config/locales/es.yml +154 -0
  22. data/config/routes.rb +41 -0
  23. data/exe/clickwrap +374 -0
  24. data/guides/README.md +30 -0
  25. data/guides/accessibility.md +249 -0
  26. data/guides/consent-and-lifecycle.md +299 -0
  27. data/guides/integrating.md +610 -0
  28. data/guides/integrity.md +212 -0
  29. data/guides/migrating.md +335 -0
  30. data/guides/naming.md +320 -0
  31. data/guides/organizations.md +320 -0
  32. data/guides/receipts-and-verification.md +415 -0
  33. data/guides/request-evidence.md +512 -0
  34. data/guides/retention-and-legal-holds.md +438 -0
  35. data/lib/clickwrap/actor_proxy.rb +147 -0
  36. data/lib/clickwrap/anonymous_actor.rb +47 -0
  37. data/lib/clickwrap/authority.rb +174 -0
  38. data/lib/clickwrap/canonical_json.rb +216 -0
  39. data/lib/clickwrap/capture/event_builder.rb +220 -0
  40. data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
  41. data/lib/clickwrap/capture.rb +650 -0
  42. data/lib/clickwrap/configuration.rb +1129 -0
  43. data/lib/clickwrap/controller_helpers.rb +758 -0
  44. data/lib/clickwrap/current_state.rb +282 -0
  45. data/lib/clickwrap/digest.rb +125 -0
  46. data/lib/clickwrap/doctor.rb +418 -0
  47. data/lib/clickwrap/document_definition.rb +255 -0
  48. data/lib/clickwrap/document_renderer.rb +83 -0
  49. data/lib/clickwrap/document_renderers/markdown.rb +175 -0
  50. data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
  51. data/lib/clickwrap/dsl/policy_builder.rb +462 -0
  52. data/lib/clickwrap/dsl/retention_builder.rb +89 -0
  53. data/lib/clickwrap/durable_commit_callback.rb +37 -0
  54. data/lib/clickwrap/engine.rb +187 -0
  55. data/lib/clickwrap/errors.rb +181 -0
  56. data/lib/clickwrap/form_builder_extensions.rb +341 -0
  57. data/lib/clickwrap/front_matter.rb +67 -0
  58. data/lib/clickwrap/identifier.rb +112 -0
  59. data/lib/clickwrap/import/external_receipt.rb +241 -0
  60. data/lib/clickwrap/import/fine_print.rb +290 -0
  61. data/lib/clickwrap/import/legacy.rb +450 -0
  62. data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
  63. data/lib/clickwrap/integrity/anchor.rb +130 -0
  64. data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
  65. data/lib/clickwrap/integrity/attestor.rb +221 -0
  66. data/lib/clickwrap/integrity/chain.rb +313 -0
  67. data/lib/clickwrap/integrity/timestamp.rb +143 -0
  68. data/lib/clickwrap/ip_geolocation/location.rb +112 -0
  69. data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
  70. data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
  71. data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
  72. data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
  73. data/lib/clickwrap/ip_geolocation.rb +16 -0
  74. data/lib/clickwrap/lifecycle.rb +534 -0
  75. data/lib/clickwrap/linter.rb +382 -0
  76. data/lib/clickwrap/localized_text.rb +101 -0
  77. data/lib/clickwrap/macros.rb +203 -0
  78. data/lib/clickwrap/models/application_record.rb +20 -0
  79. data/lib/clickwrap/models/chain_head.rb +79 -0
  80. data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
  81. data/lib/clickwrap/models/disposition_plan.rb +208 -0
  82. data/lib/clickwrap/models/document.rb +46 -0
  83. data/lib/clickwrap/models/document_version.rb +163 -0
  84. data/lib/clickwrap/models/event.rb +743 -0
  85. data/lib/clickwrap/models/event_document.rb +79 -0
  86. data/lib/clickwrap/models/event_statement.rb +92 -0
  87. data/lib/clickwrap/models/external_action.rb +150 -0
  88. data/lib/clickwrap/models/integrity_attestation.rb +90 -0
  89. data/lib/clickwrap/models/legal_hold.rb +81 -0
  90. data/lib/clickwrap/models/policy_revision.rb +115 -0
  91. data/lib/clickwrap/models/presentation.rb +59 -0
  92. data/lib/clickwrap/models/receipt_access.rb +53 -0
  93. data/lib/clickwrap/models/recording_sequence.rb +21 -0
  94. data/lib/clickwrap/models/request_evidence.rb +378 -0
  95. data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
  96. data/lib/clickwrap/models/statement_state.rb +130 -0
  97. data/lib/clickwrap/pending_receipt.rb +177 -0
  98. data/lib/clickwrap/policy.rb +283 -0
  99. data/lib/clickwrap/presentation_manifest.rb +210 -0
  100. data/lib/clickwrap/presenter.rb +729 -0
  101. data/lib/clickwrap/privacy.rb +419 -0
  102. data/lib/clickwrap/protected_outcome.rb +120 -0
  103. data/lib/clickwrap/receipt.rb +606 -0
  104. data/lib/clickwrap/receipt_html.rb +235 -0
  105. data/lib/clickwrap/receipt_verifier.rb +978 -0
  106. data/lib/clickwrap/reference.rb +44 -0
  107. data/lib/clickwrap/registration.rb +236 -0
  108. data/lib/clickwrap/registry.rb +54 -0
  109. data/lib/clickwrap/remediation_token.rb +155 -0
  110. data/lib/clickwrap/request_evidence_extractor.rb +590 -0
  111. data/lib/clickwrap/request_evidence_policy.rb +261 -0
  112. data/lib/clickwrap/retention/applier.rb +231 -0
  113. data/lib/clickwrap/retention/disposition.rb +221 -0
  114. data/lib/clickwrap/retention/planner.rb +502 -0
  115. data/lib/clickwrap/retention_class.rb +97 -0
  116. data/lib/clickwrap/reviewed_text.rb +28 -0
  117. data/lib/clickwrap/schema_requirements.rb +196 -0
  118. data/lib/clickwrap/services/authorize_external_action.rb +149 -0
  119. data/lib/clickwrap/services/load_policies.rb +69 -0
  120. data/lib/clickwrap/services/publish_documents.rb +251 -0
  121. data/lib/clickwrap/services/validate_policy_references.rb +166 -0
  122. data/lib/clickwrap/statement.rb +248 -0
  123. data/lib/clickwrap/subject_fingerprint.rb +28 -0
  124. data/lib/clickwrap/submission.rb +169 -0
  125. data/lib/clickwrap/system_actor.rb +31 -0
  126. data/lib/clickwrap/test_helpers.rb +676 -0
  127. data/lib/clickwrap/testing.rb +211 -0
  128. data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
  129. data/lib/clickwrap/verification.rb +504 -0
  130. data/lib/clickwrap/version.rb +12 -1
  131. data/lib/clickwrap/view_helpers.rb +190 -0
  132. data/lib/clickwrap/vocabulary.rb +294 -0
  133. data/lib/clickwrap.rb +497 -7
  134. data/lib/generators/clickwrap/document_generator.rb +164 -0
  135. data/lib/generators/clickwrap/hardening_generator.rb +177 -0
  136. data/lib/generators/clickwrap/install_generator.rb +1287 -0
  137. data/lib/generators/clickwrap/link_generator.rb +56 -0
  138. data/lib/generators/clickwrap/policy_generator.rb +118 -0
  139. data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
  140. data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
  141. data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
  142. data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
  143. data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
  144. data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
  145. data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
  146. data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
  147. data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
  148. data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
  149. data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
  150. data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
  151. data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
  152. data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
  153. data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
  154. data/lib/generators/clickwrap/views_generator.rb +101 -0
  155. data/lib/tasks/clickwrap.rake +569 -0
  156. metadata +311 -16
@@ -0,0 +1,438 @@
1
+ # Retention, disposition, and legal holds
2
+
3
+ Clickwrap does not choose retention periods and cannot tell you whether yours are right. What
4
+ it does is make a reviewed decision executable and auditable, keep the core event's schedule
5
+ separate from the optional personal request evidence attached to it, and support the
6
+ event-based rules that real record-keeping obligations actually use.
7
+
8
+ Every policy names a retention class. There is no default or fallback for captured payloads or
9
+ optional request evidence, and Clickwrap never picks a period. After a reviewed core disposition,
10
+ a minimal row and its digest-linked disposition successor remain without another disposal
11
+ schedule. Those tombstones document the deletion itself and are deliberately excluded from
12
+ future disposition plans; recursively deleting the proof of deletion would recreate the hole
13
+ the tombstone exists to prevent.
14
+
15
+ ---
16
+
17
+ ## Two vocabularies, because they are two intentions
18
+
19
+ ```ruby
20
+ Clickwrap.retention :ordinary_agreement_evidence do
21
+ retain_core_event_for 6.years
22
+ delete_recorded_ip_address_after 90.days
23
+ delete_recorded_browser_user_agent_after 90.days
24
+ delete_recorded_ip_geolocation_after 90.days
25
+ end
26
+ ```
27
+
28
+ `retain_..._for` and `retain_..._until` say how long evidence is kept. `delete_..._after` says
29
+ when personal request evidence goes away. They read differently on purpose: the second one is
30
+ the destructive one, and a reviewer scanning a diff should be able to see which is which
31
+ without parsing the noun.
32
+
33
+ | Part | Duration rule | Event-based rule |
34
+ |---|---|---|
35
+ | The core event | `retain_core_event_for 6.years` | `retain_core_event_until :your_host_event` |
36
+ | Recorded IP address | `delete_recorded_ip_address_after 90.days` | `retain_recorded_ip_address_until :your_host_event` |
37
+ | Recorded browser user-agent | `delete_recorded_browser_user_agent_after 90.days` | `retain_recorded_browser_user_agent_until :your_host_event` |
38
+ | Recorded IP geolocation | `delete_recorded_ip_geolocation_after 90.days` | `retain_recorded_ip_geolocation_until :your_host_event` |
39
+
40
+ A retention class that never says how long to keep the core event raises a `DefinitionError` at
41
+ boot, naming both forms. A rule whose duration is zero or negative raises too. Clickwrap will
42
+ not pick a payload-retention period for you.
43
+
44
+ ---
45
+
46
+ ## Why a duration alone is not enough
47
+
48
+ Take a real pair of obligations, both from Spanish law:
49
+
50
+ - The reviewed TRA040 v2 shared-mobility publication requires at least five years of
51
+ platform-data retention, alongside verifiable evidence that each user received a
52
+ versioned and date-stamped data-sharing notice
53
+ ([official BOE PDF](https://www.boe.es/boe/dias/2026/08/04/pdfs/BOE-A-2026-16993.pdf)) *(law)*.
54
+ - Order TED/815/2023 separately requires submitted documents to be retained until at least
55
+ three years after **liquidation**
56
+ ([Article 14.12](https://www.boe.es/eli/es/o/2023/07/18/ted815)) *(law)*.
57
+
58
+ Put together, the schedule for one record is: **five years from capture, or three years after
59
+ liquidation, whichever is later.**
60
+
61
+ A duration cannot express that. At capture time, liquidation has not happened; there is no date
62
+ to add three years to, and no way to know whether the resulting date will land before or after
63
+ the five-year mark. Encoding it as `6.years` would be a guess that is wrong in both directions
64
+ — too short when liquidation is late, and needlessly long when it is early. Encoding it as
65
+ "delete after five years" destroys regulated evidence early in exactly the cases that matter.
66
+
67
+ So the rule names a host calculation instead:
68
+
69
+ ```ruby
70
+ Clickwrap.retention :regulated_evidence do
71
+ retain_core_event_until :regulated_evidence_retention_ends
72
+ retain_recorded_ip_address_until :security_evidence_retention_ends
73
+ retain_recorded_browser_user_agent_until :security_evidence_retention_ends
74
+ retain_recorded_ip_geolocation_until :security_evidence_retention_ends
75
+ end
76
+ ```
77
+
78
+ ```ruby
79
+ Clickwrap.configure do |config|
80
+ config.calculate_retention_time_for :regulated_evidence_retention_ends do |event|
81
+ [
82
+ event.recorded_at_by_server + 5.years,
83
+ event.subject.respond_to?(:liquidated_at) ? event.subject.liquidated_at&.+(3.years) : nil
84
+ ].compact.max
85
+ end
86
+
87
+ config.calculate_retention_time_for :security_evidence_retention_ends do |event|
88
+ event.recorded_at_by_server + 2.years
89
+ end
90
+ end
91
+ ```
92
+
93
+ **Returning `nil` is a legitimate answer.** It means the triggering event has not happened yet,
94
+ so the clock has not started. The planner reports that item as `unresolved` — a third category
95
+ alongside `due` and `held` — rather than inventing a date or treating "we cannot say yet" as
96
+ "delete it now." That failure mode is specific and foreseeable, and it is the reason the report
97
+ has three columns instead of two.
98
+
99
+ Three ways a host calculation can fail to produce a date, all reported as `unresolved` with the
100
+ reason attached rather than crashing the run: it returns `nil`, it is not registered, or it
101
+ raises. A disposition run that dies on one row, or that guesses a date to keep going, is worse
102
+ than one that says which rows it could not evaluate.
103
+
104
+ Note which schedules the planner reads: **the ones recorded on each row when that row was
105
+ created**, not today's policy. The capture event, a later withdrawal, and a still-later expiry
106
+ each freeze their own core schedule. The annex freezes each enabled category's schedule. Changing
107
+ a policy changes future events; it does not silently reschedule records already written.
108
+
109
+ Linked events age independently. Disposing of a root event does not cascade to a retained
110
+ withdrawal, expiry, consumption, correction, or other successor. A current-state projection is
111
+ removed only when the exact event it points at is disposed of. This prevents an older root's
112
+ deadline from erasing a newer lifecycle fact early.
113
+
114
+ The deadline boundary is inclusive: an item is due when its recorded or resolved deadline is
115
+ less than or equal to the planner's `at` time. It does not wait for a later clock tick.
116
+
117
+ ---
118
+
119
+ ## Plan, then apply
120
+
121
+ Disposition is always two steps.
122
+
123
+ ```bash
124
+ bin/rails clickwrap:retention:plan
125
+ ```
126
+
127
+ ```text
128
+ Disposition plan 01K2Y8T5QY0N4V6N1H4G4CQY8J
129
+ due: 4182
130
+ held: 12 (a legal hold is pausing these)
131
+ unresolved: 305 (a host event has not happened yet)
132
+ ```
133
+
134
+ The plan deletes nothing, marks nothing, and changes no state anywhere else. An operator can
135
+ run it on a Friday afternoon without consequence. It can be narrowed with `POLICY=`, `ACTOR=`,
136
+ `BY=`, and `BECAUSE=`, and it covers five parts: the four parts of an event plus persisted
137
+ presentations, which are not evidence of an act and age out on their own schedule.
138
+
139
+ Held items are counted separately rather than dropped, so an operator who expected four
140
+ thousand items and sees twelve can tell that a hold is working rather than that the query is
141
+ broken.
142
+
143
+ ```bash
144
+ bin/rails clickwrap:retention:apply PLAN=01K2Y8T5QY0N4V6N1H4G4CQY8J
145
+ ```
146
+
147
+ ### Why apply re-checks
148
+
149
+ The applier does not trust the plan. It re-derives every item's eligibility and re-checks every
150
+ hold, and it reports skips in two named buckets — `skipped_held` and `skipped_changed` — rather
151
+ than one. The reasons it can refuse:
152
+
153
+ | Condition | What happens |
154
+ |---|---|
155
+ | A legal hold was placed after the plan was built | The item is skipped and recorded as held. The hold wins even if it lands between the check and the write |
156
+ | The retention class changed, or is gone | The item is skipped as changed |
157
+ | The item is no longer due — a host calculation now returns a later date, or none | Skipped as changed |
158
+ | The record was already deleted, or no longer exists | Skipped as changed |
159
+ | The plan expired | The whole run refuses. Plans live 24 hours by default |
160
+ | The plan was already applied, or superseded by a newer one | The whole run refuses, naming which |
161
+ | A worker died after claiming the plan | The plan stays claimed. Recovery requires an explicit stale duration, a new operator reference, and a plain-English recovery reason; every item is rechecked, so already-committed dispositions are reported rather than repeated |
162
+
163
+ Each refusal names its specific reason, so an operator sees "the plan expired" or "this was
164
+ already applied" rather than a generic decline. The run exits non-zero if anything was skipped
165
+ or errored, so a scheduled job cannot quietly do less than it reported.
166
+
167
+ The point of the two-step is not ceremony. It is that the set an operator reviewed and the set
168
+ that gets deleted must be the same set, and between the review and the click, someone may have
169
+ placed a hold.
170
+
171
+ ### Deleting one field directly
172
+
173
+ ```ruby
174
+ Clickwrap.delete_recorded_ip_address!(receipt, because: "Retention period ended")
175
+ Clickwrap.delete_recorded_browser_user_agent!(receipt, because: "Retention period ended")
176
+ Clickwrap.delete_recorded_ip_geolocation!(receipt, because: "Retention period ended")
177
+ ```
178
+
179
+ Three methods, one per field, each naming exactly what it removes. There is deliberately no
180
+ `delete_personal_data!`, no `purge_network_context!`, and no method that takes out three
181
+ categories at once: someone reading a disposition report a year from now has to be able to see
182
+ which value disappeared, not a euphemism covering several. Each requires a plain-English
183
+ `because:`, refuses while a hold is in effect, and appends its own `disposition` event.
184
+
185
+ Deleting a field that was never recorded, or was already deleted, is a no-op that appends no
186
+ event — an event saying "deleted" where nothing was ever recorded would be false.
187
+
188
+ ---
189
+
190
+ ## What deletion changes about a receipt, and what it does not
191
+
192
+ Deleting the recorded IP address does **not**:
193
+
194
+ - rewrite the historical agreement, declaration, or authorization;
195
+ - touch any column of the core event;
196
+ - change the event's `request_evidence_digest`;
197
+ - make a previously verifying event stop verifying.
198
+
199
+ The event's canonical body excludes every annex value on purpose. That is the entire reason the
200
+ annex is a separate table. An ordinary retention run cannot make a verified event fail
201
+ verification, because deletion changes what a receipt can *show*, never what it *says
202
+ happened*.
203
+
204
+ What it does change:
205
+
206
+ | Before | After |
207
+ |---|---|
208
+ | `{"state": "recorded"}`, or `{"state": "redacted_for_this_viewer"}` for an unauthorized reader | `{"state": "deleted_after_retention", "deleted_at": "2026-11-13T09:00:00.000000Z"}` |
209
+ | The value column holds the value | The value column is `nil` |
210
+ | — | A linked `disposition` event carries the field name, the reason, and who ran it |
211
+
212
+ Provenance survives deliberately. The reader name, the trusted-proxy configuration digest, the
213
+ recorded-at timestamp, the provider name, the database version, the accuracy metadata, and the
214
+ rule the deletion ran under all stay. Erasing them too would turn a documented deletion into a
215
+ gap, which is the one outcome a retention process must never produce.
216
+
217
+ One consequence is worth stating rather than discovering: the keyed binding digest recorded at
218
+ capture stays as written, and after deletion it no longer recomputes from the annex. That is
219
+ the expected result of a permitted deletion, not a sign of interference, and the receipt says
220
+ which by reporting the field as `deleted_after_retention` with its timestamp.
221
+
222
+ **Disposing of the core event** is deliberately different from deleting one annex value. In one
223
+ transaction Clickwrap appends a digest-checked `disposition` successor, records its ID and time
224
+ on the original row, clears the fixed core payload and statement/document bindings, and leaves
225
+ the original row at its chain position. The original digest can no longer be re-derived from a
226
+ payload that was intentionally removed, so verification reports a
227
+ `documented_core_disposition` separately from an ordinary verifying digest. A bare disposition
228
+ marker, a lookalike event, a wrong predecessor/root link, or mismatched original digest fails
229
+ closed as an integrity error. The surrounding chain can still be walked using the retained
230
+ original digest and the linked successor.
231
+
232
+ One operational boundary follows from intentional deletion: current state can be rebuilt from
233
+ retained event payloads, but not from personal statement identity that a reviewed root
234
+ disposition removed. `CurrentState.rebuild_for!` refuses before deleting an existing projection
235
+ when it can see that dependency. Do not drop `clickwrap_statement_states` after core disposition
236
+ has begun and expect disposed identity facts to reappear; that would defeat the disposition.
237
+
238
+ A retained digest is described as a **retained linkable digest**, never automatically called
239
+ anonymous. The binding digest is keyed rather than a plain hash for a concrete reason: an IPv4
240
+ address is 32 bits ([RFC 791](https://www.rfc-editor.org/info/rfc791/)) *(technical standard)*,
241
+ so an unsalted hash of one can be tested by enumerating every address in minutes. Even keyed,
242
+ treat the result as pseudonymous data that outlives the value it covers, and analyze it that
243
+ way under your own privacy model.
244
+
245
+ ---
246
+
247
+ ## Legal holds
248
+
249
+ ```ruby
250
+ receipt.place_on_legal_hold!(
251
+ because: "Pending dispute 2026-184",
252
+ placed_by: current_operator,
253
+ review_at: 6.months.from_now
254
+ )
255
+
256
+ receipt.release_legal_hold!(
257
+ because: "Dispute resolved",
258
+ released_by: current_operator
259
+ )
260
+ ```
261
+
262
+ All three arguments are required, and each one is load-bearing:
263
+
264
+ - **`because:`** — an indefinite hold nobody can explain is indistinguishable from a bug.
265
+ - **`placed_by:`** — a hold with no owner has nobody to ask about it.
266
+ - **`review_at:`** — a hold nobody revisits is exactly how "we'll delete it later" becomes "we
267
+ kept everything forever." A retention policy that can be suspended invisibly is not a
268
+ retention policy.
269
+
270
+ Hold history uses named append transitions. Placing one appends a `legal_hold_placed` event;
271
+ releasing one appends a `legal_hold_released` event and records who released it and why. They
272
+ come in three scopes — `event`, `actor`, and `policy` — and all three are checked before any
273
+ deletion. A hold on an actor's whole file is not weaker than one on a single receipt.
274
+
275
+ ```bash
276
+ bin/rails clickwrap:holds:review
277
+ ```
278
+
279
+ lists every hold in effect and, separately, the ones past their review date.
280
+
281
+ ---
282
+
283
+ ## Actor deletion does not cascade
284
+
285
+ `has_clickwraps` deliberately does not add a `dependent: :destroy`. Deleting an account must not
286
+ silently erase the record of what that person agreed to — that is a retention decision, and it
287
+ belongs to you rather than to a foreign key.
288
+
289
+ Instead: the installer generates restrictive or nullifying relationships, the association
290
+ nullifies the actor link, and the evidence keeps the stable pseudonymous reference produced by
291
+ `config.identify_actor_with` (a model override, GlobalID when available, or a stable class/id
292
+ fallback). What happens next is whatever your
293
+ retention policy says.
294
+
295
+ For an erasure request, the tooling produces a reviewable plan rather than a verdict:
296
+
297
+ ```bash
298
+ bin/rails clickwrap:privacy:disposition:plan ACTOR=gid://my-app/User/123
299
+ ```
300
+
301
+ ```ruby
302
+ Clickwrap::Privacy.plan_disposition_for(
303
+ actor,
304
+ requested_by: current_operator,
305
+ because: "Verified erasure request DSAR-2026-41"
306
+ )
307
+ ```
308
+
309
+ The plan shows what would happen. It does not decide whether an erasure request overrides a
310
+ retention duty, a legal claim, or a hold. That decision is yours, and the plan is what you make
311
+ it with.
312
+
313
+ ---
314
+
315
+ ## A worked example, end to end
316
+
317
+ A regulated payout authorization: five years or three years after liquidation, whichever is
318
+ later, for the core event; two years for the request evidence.
319
+
320
+ **1. Declare the retention class and register the calculations.**
321
+
322
+ ```ruby
323
+ # config/clickwrap.rb
324
+ Clickwrap.retention :regulated_evidence do
325
+ retain_core_event_until :regulated_evidence_retention_ends
326
+ retain_recorded_ip_address_until :security_evidence_retention_ends
327
+ retain_recorded_browser_user_agent_until :security_evidence_retention_ends
328
+ retain_recorded_ip_geolocation_until :security_evidence_retention_ends
329
+ end
330
+
331
+ Clickwrap.configure do |config|
332
+ config.calculate_retention_time_for :regulated_evidence_retention_ends do |event|
333
+ [
334
+ event.recorded_at_by_server + 5.years,
335
+ event.subject.respond_to?(:liquidated_at) ? event.subject.liquidated_at&.+(3.years) : nil
336
+ ].compact.max
337
+ end
338
+
339
+ config.calculate_retention_time_for :security_evidence_retention_ends do |event|
340
+ event.recorded_at_by_server + 2.years
341
+ end
342
+ end
343
+ ```
344
+
345
+ **2. Point the policy at it.**
346
+
347
+ ```ruby
348
+ Clickwrap.policy :regulated_authorization do
349
+ authorize :regulated_action, one_time: true, valid_for: 10.minutes
350
+
351
+ review_request_evidence_configuration_on Date.new(2027, 8, 15)
352
+
353
+ record_ip_address(
354
+ encrypted: true,
355
+ retain_until: :security_evidence_retention_ends,
356
+ because: "Investigate account compromise and disputes about this action",
357
+ legal_basis_reference: "LIA-SECURITY-2026-01"
358
+ )
359
+
360
+ retain_with :regulated_evidence
361
+ end
362
+ ```
363
+
364
+ **3. Capture. The schedule is written onto the row.** The annex gets
365
+ `ip_address_retain_until_rule = "security_evidence_retention_ends"`. The event gets its
366
+ retention class and, where the rule resolves at capture, a computed
367
+ `retain_core_event_until`.
368
+
369
+ **4. Two years pass. Run the plan.**
370
+
371
+ ```bash
372
+ bin/rails clickwrap:retention:plan POLICY=regulated_authorization BY=ops@example.com
373
+ ```
374
+
375
+ The IP address is `due`. The core event is `unresolved` — the calculation returns
376
+ `recorded_at + 5.years`, which has not arrived — so it is not offered for deletion at all.
377
+
378
+ **5. Someone opens a dispute. Place a hold before applying.**
379
+
380
+ ```ruby
381
+ Clickwrap.receipt(event_id).place_on_legal_hold!(
382
+ because: "Pending dispute 2026-184",
383
+ placed_by: current_operator,
384
+ review_at: 6.months.from_now
385
+ )
386
+ ```
387
+
388
+ **6. Apply the plan anyway.**
389
+
390
+ ```bash
391
+ bin/rails clickwrap:retention:apply PLAN=01K2Y8T5QY0N4V6N1H4G4CQY8J
392
+ ```
393
+
394
+ That item lands in `skipped_held` with the note naming the event, and the task exits non-zero.
395
+ The hold was placed after the plan was built and it still wins, because the applier re-checks
396
+ rather than trusting the plan. Nothing was deleted for that event.
397
+
398
+ **7. The dispute resolves.**
399
+
400
+ ```ruby
401
+ receipt.release_legal_hold!(because: "Dispute resolved", released_by: current_operator)
402
+ ```
403
+
404
+ **8. Plan and apply again.** This time the IP address is deleted. The receipt now reports:
405
+
406
+ ```json
407
+ "request_evidence": {
408
+ "ip_address": {
409
+ "state": "deleted_after_retention",
410
+ "deleted_at": "2028-09-02T11:04:31.882410Z"
411
+ },
412
+ "browser_user_agent": { "state": "not_configured" },
413
+ "ip_geolocation": { "state": "not_configured" }
414
+ }
415
+ ```
416
+
417
+ A `disposition` event now hangs off the original, carrying the field name and the reason. The
418
+ authorization itself is untouched: same statements, same documents, same digests, still
419
+ verifying.
420
+
421
+ **9. Three years later, or three years after liquidation — whichever the calculation returns —**
422
+ the core event becomes due, and the same two-step disposes of it. `core_event_disposed_at` is
423
+ set, another `disposition` event explains it, and the row stays where an auditor can find it.
424
+
425
+ ---
426
+
427
+ ## Sources
428
+
429
+ | Source | Class |
430
+ |---|---|
431
+ | [TRA040 v2, official BOE PDF](https://www.boe.es/boe/dias/2026/08/04/pdfs/BOE-A-2026-16993.pdf) — at least five years of platform-data retention | Law |
432
+ | [Order TED/815/2023, Article 14.12](https://www.boe.es/eli/es/o/2023/07/18/ted815) — retention until at least three years after liquidation | Law |
433
+ | [GDPR Article 5](https://eur-lex.europa.eu/eli/reg/2016/679/art_5/oj/eng) (storage limitation), [Article 17](https://eur-lex.europa.eu/eli/reg/2016/679/art_17/oj/eng) (erasure, with its exceptions including legal claims) | Law |
434
+ | [RFC 791](https://www.rfc-editor.org/info/rfc791/) — IPv4 addresses are 32 bits | Technical standard |
435
+ | The plan/apply split, the three-category report, and the hold requirements | Product-design inference |
436
+
437
+ The periods in every example above are placeholders. Clickwrap does not know your jurisdiction,
438
+ your sector, or your obligations, and nothing here is a recommendation about any of them.
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # `user.clickwraps` — the everyday API.
5
+ #
6
+ # Each predicate reads as the question it answers, and each one asks about the
7
+ # specific act it names. `agreed_to?(:terms)` is not the same question as
8
+ # `consented_to?(:marketing)`, and neither is answered by a generic
9
+ # `accepted_at` timestamp. That is the whole point of having six kinds.
10
+ #
11
+ # Two things these predicates deliberately never do. They never treat a system
12
+ # exemption as a human action — an exemption answers `exempted_from?` and
13
+ # nothing else. And they never infer a "yes" from missing data: no evidence
14
+ # means no, every time.
15
+ class ActorProxy
16
+ def initialize(actor)
17
+ @actor = actor
18
+ end
19
+
20
+ attr_reader :actor
21
+
22
+ # --- Predicates -----------------------------------------------------------
23
+
24
+ def current_for?(policy_key, subject: nil, tenant: nil, acting_for: nil)
25
+ Clickwrap.current?(policy_key, actor: actor, subject: subject, tenant: tenant,
26
+ acting_for: acting_for)
27
+ end
28
+
29
+ def required_for?(policy_key, subject: nil, tenant: nil, acting_for: nil)
30
+ !current_for?(policy_key, subject: subject, tenant: tenant, acting_for: acting_for)
31
+ end
32
+
33
+ def agreed_to?(statement_key, subject: nil, tenant: nil, acting_for: nil)
34
+ satisfied?("agreement", statement_key, subject: subject, tenant: tenant, acting_for: acting_for)
35
+ end
36
+
37
+ def acknowledged?(statement_key, subject: nil, tenant: nil, acting_for: nil)
38
+ satisfied?("acknowledgment", statement_key, subject: subject, tenant: tenant, acting_for: acting_for)
39
+ end
40
+
41
+ def consented_to?(purpose_key, subject: nil, tenant: nil, acting_for: nil)
42
+ state = state_for_purpose(purpose_key, subject: subject, tenant: tenant, acting_for: acting_for)
43
+ !state.nil? && state.satisfies?
44
+ end
45
+
46
+ def declared?(statement_key, subject: nil, tenant: nil, acting_for: nil)
47
+ satisfied?("declaration", statement_key, subject: subject, tenant: tenant, acting_for: acting_for)
48
+ end
49
+
50
+ def attested?(statement_key, subject: nil, tenant: nil, acting_for: nil)
51
+ satisfied?("attestation", statement_key, subject: subject, tenant: tenant, acting_for: acting_for)
52
+ end
53
+
54
+ def authorized?(statement_key, subject: nil, tenant: nil, acting_for: nil)
55
+ satisfied?("authorization", statement_key, subject: subject, tenant: tenant, acting_for: acting_for)
56
+ end
57
+
58
+ # Deliberately a separate question. An exemption records that no human
59
+ # action occurred, so it can never answer `agreed_to?` — the whole reason to
60
+ # record one is that the difference matters.
61
+ def exempted_from?(policy_key, subject: nil, tenant: nil, acting_for: nil)
62
+ states(subject: subject, tenant: tenant, acting_for: acting_for)
63
+ .for_policy(policy_key)
64
+ .where(state: "exempted")
65
+ .exists?
66
+ end
67
+
68
+ # --- Records --------------------------------------------------------------
69
+
70
+ def declaration(statement_key, subject: nil, tenant: nil, acting_for: nil)
71
+ state_for("declaration", statement_key, subject: subject, tenant: tenant, acting_for: acting_for)
72
+ end
73
+
74
+ def consent(purpose_key, subject: nil, tenant: nil, acting_for: nil)
75
+ state_for_purpose(purpose_key, subject: subject, tenant: tenant, acting_for: acting_for)
76
+ end
77
+
78
+ def authorization(statement_key, subject: nil, tenant: nil, acting_for: nil)
79
+ state_for("authorization", statement_key, subject: subject, tenant: tenant, acting_for: acting_for)
80
+ end
81
+
82
+ def events
83
+ Event.for_actor(actor_reference).chronological
84
+ end
85
+
86
+ def receipts
87
+ ReceiptCollection.new(events.where(event_type: Vocabulary::ACTOR_RECEIPT_EVENT_TYPES))
88
+ end
89
+
90
+ def statement_states = StatementState.for_actor(actor_reference)
91
+
92
+ def to_s = "clickwraps for #{actor_reference}"
93
+
94
+ private
95
+
96
+ def actor_reference
97
+ @actor_reference ||= Reference.actor(actor)
98
+ end
99
+
100
+ def states(subject:, tenant:, acting_for:)
101
+ StatementState.for_actor(actor_reference).where(
102
+ subject_key: StatementState.subject_key_for(subject),
103
+ tenant_key: Reference.tenant(tenant),
104
+ represented_party_reference: Reference.represented_party(acting_for)
105
+ )
106
+ end
107
+
108
+ def satisfied?(kind, statement_key, subject:, tenant:, acting_for:)
109
+ state = state_for(kind, statement_key, subject: subject, tenant: tenant, acting_for: acting_for)
110
+ !state.nil? && state.satisfies?
111
+ end
112
+
113
+ def state_for(kind, statement_key, subject:, tenant:, acting_for:)
114
+ states(subject: subject, tenant: tenant, acting_for: acting_for)
115
+ .for_statement(statement_key)
116
+ .where(kind: kind)
117
+ .order(effective_at: :desc)
118
+ .first
119
+ end
120
+
121
+ def state_for_purpose(purpose_key, subject:, tenant:, acting_for:)
122
+ states(subject: subject, tenant: tenant, acting_for: acting_for)
123
+ .where(kind: "consent")
124
+ .for_purpose(purpose_key)
125
+ .order(effective_at: :desc)
126
+ .first
127
+ end
128
+
129
+ # A lazily-mapped collection so `user.clickwraps.receipts.last` reads
130
+ # naturally without loading every event a person ever produced.
131
+ class ReceiptCollection
132
+ include Enumerable
133
+
134
+ def initialize(scope)
135
+ @scope = scope
136
+ end
137
+
138
+ def each(&) = @scope.each { |event| yield Receipt.new(event) }
139
+ def last = @scope.last&.then { |event| Receipt.new(event) }
140
+ def first = @scope.first&.then { |event| Receipt.new(event) }
141
+ def size = @scope.count
142
+ alias count size
143
+ def empty? = @scope.empty?
144
+ def find(event_id) = @scope.find_by(id: event_id)&.then { |event| Receipt.new(event) }
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # An actor who is not a persisted record: someone completing a checkout, an
5
+ # applicant, a visitor acting before any account exists.
6
+ #
7
+ # The identifier is host-owned and opaque, and the host owns any later account
8
+ # linking and every identity or capacity question that goes with it.
9
+ #
10
+ # An IP address is explicitly not acceptable here. It is not stable, it is not
11
+ # unique to a person, it is shared by households and offices and whole
12
+ # networks, and using one as an actor identifier would put a claim in the
13
+ # evidence that the evidence cannot support. The constructor refuses one
14
+ # rather than letting it become a hard-to-notice mistake.
15
+ class AnonymousActor
16
+ IP_ADDRESS_PATTERN = /\A(\d{1,3}\.){3}\d{1,3}\z|\A[0-9a-f:]+:[0-9a-f:]*\z/i
17
+
18
+ attr_reader :identifier
19
+
20
+ def initialize(identifier)
21
+ @identifier = identifier.to_s
22
+
23
+ if @identifier.strip.empty?
24
+ raise ArgumentError,
25
+ "An anonymous actor needs a stable identifier your application owns, for example " \
26
+ "\"checkout_#{signed_checkout_id}\"."
27
+ end
28
+
29
+ if IP_ADDRESS_PATTERN.match?(@identifier)
30
+ raise ArgumentError,
31
+ "#{@identifier.inspect} looks like an IP address. An IP address is not an actor: " \
32
+ "it is shared, reassigned, and proxied, so evidence attributed to one would claim " \
33
+ "more than it can support. Use a stable identifier your application controls."
34
+ end
35
+
36
+ freeze
37
+ end
38
+
39
+ def clickwrap_actor_reference = "anonymous/#{identifier}"
40
+ def id = identifier
41
+ def to_s = clickwrap_actor_reference
42
+ def persisted? = false
43
+ def ==(other) = other.is_a?(self.class) && other.identifier == identifier
44
+ alias eql? ==
45
+ def hash = [self.class, identifier].hash
46
+ end
47
+ end