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.
- checksums.yaml +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +650 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +245 -0
- data/app/controllers/clickwrap/application_controller.rb +79 -0
- data/app/controllers/clickwrap/captures_controller.rb +145 -0
- data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
- data/app/controllers/clickwrap/receipts_controller.rb +115 -0
- data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
- data/app/helpers/clickwrap/engine_helper.rb +97 -0
- data/app/views/clickwrap/captures/show.html.erb +34 -0
- data/app/views/clickwrap/receipts/index.html.erb +38 -0
- data/app/views/clickwrap/receipts/show.html.erb +91 -0
- data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
- data/app/views/clickwrap/shared/_fields.html.erb +100 -0
- data/app/views/clickwrap/shared/_statement.html.erb +105 -0
- data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
- data/config/locales/en.yml +160 -0
- data/config/locales/es.yml +154 -0
- data/config/routes.rb +41 -0
- data/exe/clickwrap +374 -0
- data/guides/README.md +30 -0
- data/guides/accessibility.md +249 -0
- data/guides/consent-and-lifecycle.md +299 -0
- data/guides/integrating.md +610 -0
- data/guides/integrity.md +212 -0
- data/guides/migrating.md +335 -0
- data/guides/naming.md +320 -0
- data/guides/organizations.md +320 -0
- data/guides/receipts-and-verification.md +415 -0
- data/guides/request-evidence.md +512 -0
- data/guides/retention-and-legal-holds.md +438 -0
- data/lib/clickwrap/actor_proxy.rb +147 -0
- data/lib/clickwrap/anonymous_actor.rb +47 -0
- data/lib/clickwrap/authority.rb +174 -0
- data/lib/clickwrap/canonical_json.rb +216 -0
- data/lib/clickwrap/capture/event_builder.rb +220 -0
- data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
- data/lib/clickwrap/capture.rb +650 -0
- data/lib/clickwrap/configuration.rb +1129 -0
- data/lib/clickwrap/controller_helpers.rb +758 -0
- data/lib/clickwrap/current_state.rb +282 -0
- data/lib/clickwrap/digest.rb +125 -0
- data/lib/clickwrap/doctor.rb +418 -0
- data/lib/clickwrap/document_definition.rb +255 -0
- data/lib/clickwrap/document_renderer.rb +83 -0
- data/lib/clickwrap/document_renderers/markdown.rb +175 -0
- data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
- data/lib/clickwrap/dsl/policy_builder.rb +462 -0
- data/lib/clickwrap/dsl/retention_builder.rb +89 -0
- data/lib/clickwrap/durable_commit_callback.rb +37 -0
- data/lib/clickwrap/engine.rb +187 -0
- data/lib/clickwrap/errors.rb +181 -0
- data/lib/clickwrap/form_builder_extensions.rb +341 -0
- data/lib/clickwrap/front_matter.rb +67 -0
- data/lib/clickwrap/identifier.rb +112 -0
- data/lib/clickwrap/import/external_receipt.rb +241 -0
- data/lib/clickwrap/import/fine_print.rb +290 -0
- data/lib/clickwrap/import/legacy.rb +450 -0
- data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
- data/lib/clickwrap/integrity/anchor.rb +130 -0
- data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
- data/lib/clickwrap/integrity/attestor.rb +221 -0
- data/lib/clickwrap/integrity/chain.rb +313 -0
- data/lib/clickwrap/integrity/timestamp.rb +143 -0
- data/lib/clickwrap/ip_geolocation/location.rb +112 -0
- data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
- data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
- data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
- data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
- data/lib/clickwrap/ip_geolocation.rb +16 -0
- data/lib/clickwrap/lifecycle.rb +534 -0
- data/lib/clickwrap/linter.rb +382 -0
- data/lib/clickwrap/localized_text.rb +101 -0
- data/lib/clickwrap/macros.rb +203 -0
- data/lib/clickwrap/models/application_record.rb +20 -0
- data/lib/clickwrap/models/chain_head.rb +79 -0
- data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
- data/lib/clickwrap/models/disposition_plan.rb +208 -0
- data/lib/clickwrap/models/document.rb +46 -0
- data/lib/clickwrap/models/document_version.rb +163 -0
- data/lib/clickwrap/models/event.rb +743 -0
- data/lib/clickwrap/models/event_document.rb +79 -0
- data/lib/clickwrap/models/event_statement.rb +92 -0
- data/lib/clickwrap/models/external_action.rb +150 -0
- data/lib/clickwrap/models/integrity_attestation.rb +90 -0
- data/lib/clickwrap/models/legal_hold.rb +81 -0
- data/lib/clickwrap/models/policy_revision.rb +115 -0
- data/lib/clickwrap/models/presentation.rb +59 -0
- data/lib/clickwrap/models/receipt_access.rb +53 -0
- data/lib/clickwrap/models/recording_sequence.rb +21 -0
- data/lib/clickwrap/models/request_evidence.rb +378 -0
- data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
- data/lib/clickwrap/models/statement_state.rb +130 -0
- data/lib/clickwrap/pending_receipt.rb +177 -0
- data/lib/clickwrap/policy.rb +283 -0
- data/lib/clickwrap/presentation_manifest.rb +210 -0
- data/lib/clickwrap/presenter.rb +729 -0
- data/lib/clickwrap/privacy.rb +419 -0
- data/lib/clickwrap/protected_outcome.rb +120 -0
- data/lib/clickwrap/receipt.rb +606 -0
- data/lib/clickwrap/receipt_html.rb +235 -0
- data/lib/clickwrap/receipt_verifier.rb +978 -0
- data/lib/clickwrap/reference.rb +44 -0
- data/lib/clickwrap/registration.rb +236 -0
- data/lib/clickwrap/registry.rb +54 -0
- data/lib/clickwrap/remediation_token.rb +155 -0
- data/lib/clickwrap/request_evidence_extractor.rb +590 -0
- data/lib/clickwrap/request_evidence_policy.rb +261 -0
- data/lib/clickwrap/retention/applier.rb +231 -0
- data/lib/clickwrap/retention/disposition.rb +221 -0
- data/lib/clickwrap/retention/planner.rb +502 -0
- data/lib/clickwrap/retention_class.rb +97 -0
- data/lib/clickwrap/reviewed_text.rb +28 -0
- data/lib/clickwrap/schema_requirements.rb +196 -0
- data/lib/clickwrap/services/authorize_external_action.rb +149 -0
- data/lib/clickwrap/services/load_policies.rb +69 -0
- data/lib/clickwrap/services/publish_documents.rb +251 -0
- data/lib/clickwrap/services/validate_policy_references.rb +166 -0
- data/lib/clickwrap/statement.rb +248 -0
- data/lib/clickwrap/subject_fingerprint.rb +28 -0
- data/lib/clickwrap/submission.rb +169 -0
- data/lib/clickwrap/system_actor.rb +31 -0
- data/lib/clickwrap/test_helpers.rb +676 -0
- data/lib/clickwrap/testing.rb +211 -0
- data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
- data/lib/clickwrap/verification.rb +504 -0
- data/lib/clickwrap/version.rb +12 -1
- data/lib/clickwrap/view_helpers.rb +190 -0
- data/lib/clickwrap/vocabulary.rb +294 -0
- data/lib/clickwrap.rb +497 -7
- data/lib/generators/clickwrap/document_generator.rb +164 -0
- data/lib/generators/clickwrap/hardening_generator.rb +177 -0
- data/lib/generators/clickwrap/install_generator.rb +1287 -0
- data/lib/generators/clickwrap/link_generator.rb +56 -0
- data/lib/generators/clickwrap/policy_generator.rb +118 -0
- data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
- data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
- data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
- data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
- data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
- data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
- data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
- data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
- data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
- data/lib/generators/clickwrap/views_generator.rb +101 -0
- data/lib/tasks/clickwrap.rake +569 -0
- 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
|