clickwrap 0.1.1 → 0.2.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/CHANGELOG.md +49 -0
- data/README.md +57 -7
- data/lib/clickwrap/configuration.rb +55 -6
- data/lib/clickwrap/dsl/retention_builder.rb +24 -2
- data/lib/clickwrap/models/event.rb +3 -1
- data/lib/clickwrap/policy.rb +8 -7
- data/lib/clickwrap/privacy.rb +1 -0
- data/lib/clickwrap/registry.rb +14 -2
- data/lib/clickwrap/request_evidence_extractor.rb +23 -9
- data/lib/clickwrap/request_evidence_policy.rb +8 -5
- data/lib/clickwrap/retention/planner.rb +10 -1
- data/lib/clickwrap/retention_class.rb +31 -21
- data/lib/clickwrap/services/validate_policy_references.rb +21 -4
- data/lib/clickwrap/version.rb +1 -1
- data/lib/clickwrap.rb +17 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be18f8fc09eee066b71a234a134440010c6cf2beb82987b4bbe3636dde2f9133
|
|
4
|
+
data.tar.gz: a2c66a9be09cf999de30d890c4338e084146414cadbe0747022a3aedc8a4ef5f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 891f939a6b827915a5d2703ecf98b0ed9b9e761d3689a1117e8ba3df6ae0450c50a3f9d53edd0cbf844e996a7be447b080070b6ab2d916409b5d1ecfdc851ddc
|
|
7
|
+
data.tar.gz: 2387391b3d960d05dcdc783221bb5653de21406ecd0173b8fbafa1dc17d716549044a22bc719740892553b8a9c66beb8b42417c61e9bc7f585d569849fa43d9d
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.1] - 2026-08-19
|
|
10
|
+
|
|
11
|
+
### Added — request evidence can keep pace with the evidence it corroborates
|
|
12
|
+
|
|
13
|
+
- **`keep_recorded_{ip_addresses,browser_user_agents,ip_geolocation}_indefinitely!(because:)`.**
|
|
14
|
+
By-default request evidence used to demand a deletion clock, which — after
|
|
15
|
+
0.2.0 flipped core evidence to keep-indefinitely — scheduled the
|
|
16
|
+
corroboration (IP, user agent, geolocation) to expire before the agreement
|
|
17
|
+
it corroborates. The third option now exists and must be said out loud with
|
|
18
|
+
a reason, like every escape hatch here. Declaring both a clock and
|
|
19
|
+
keep-indefinitely is refused as opposite decisions.
|
|
20
|
+
|
|
21
|
+
### Documentation
|
|
22
|
+
|
|
23
|
+
- The request-evidence README section now argues FOR recording: IP + user
|
|
24
|
+
agent + geolocation are what cement a recorded act to a person when the
|
|
25
|
+
dispute is "that wasn't me", and the recommended posture is on-by-default
|
|
26
|
+
with the purpose written down. The discipline is unchanged — no silent
|
|
27
|
+
enablement, per-field decisions, encryption, reviewed proxy provenance.
|
|
28
|
+
|
|
29
|
+
## [0.2.0] - 2026-08-19
|
|
30
|
+
|
|
31
|
+
### Changed — evidence is kept indefinitely by default
|
|
32
|
+
|
|
33
|
+
- **The default retention posture is now indefinite.** A policy that never
|
|
34
|
+
says `retain_with` runs under a new built-in retention class,
|
|
35
|
+
`evidence_kept_indefinitely`: no deletion clock on the core event, none on
|
|
36
|
+
any request evidence. Previously such a policy refused to boot. The
|
|
37
|
+
direction is deliberate: keeping is reversible — a reviewed disposition can
|
|
38
|
+
always run later — while deletion is not, and the day contractual evidence
|
|
39
|
+
matters is usually years past every convenient schedule. Deletion is the
|
|
40
|
+
explicit, reviewed opt-in it always was: declare a class with clocks and
|
|
41
|
+
name it on the policy.
|
|
42
|
+
- **A retention class may keep the core event forever.** New DSL verb
|
|
43
|
+
`retain_core_event_indefinitely` says the default out loud; omitting the
|
|
44
|
+
core-event rule now means the same thing instead of raising. Snapshots
|
|
45
|
+
record `{"indefinite" => true}`, the privacy inventory reports
|
|
46
|
+
`{"kind" => "indefinite"}`, events under such a class freeze no deadline,
|
|
47
|
+
and the retention planner never lists them as due — on any horizon.
|
|
48
|
+
- **Registries can carry built-in seeds.** `Registry#clear` (every reload)
|
|
49
|
+
now returns a seeded registry to its built-ins instead of to nothing, which
|
|
50
|
+
is what keeps the default retention class alive across `to_prepare`.
|
|
51
|
+
|
|
52
|
+
### Documentation
|
|
53
|
+
|
|
54
|
+
- The README installs from rubygems.org (`gem "clickwrap"`), documents the
|
|
55
|
+
new retention default, and shows HTML pages and runtime `resolver:` sources
|
|
56
|
+
for legal documents alongside Markdown.
|
|
57
|
+
|
|
9
58
|
## [0.1.1] - 2026-08-19
|
|
10
59
|
|
|
11
60
|
### Fixed — the composed sentence in a language that declines its articles
|
data/README.md
CHANGED
|
@@ -72,6 +72,16 @@ Clickwrap.document :handbook,
|
|
|
72
72
|
from: Rails.root.join("app/content/legal/handbook.pdf")
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
Markdown is a convention here, not a requirement. `from:` takes HTML pages (`.html`/`.htm`), plain text, JSON, and PDF just as readily — the media type is inferred from the extension or named with `media_type:` — and an HTML source is sanitized at publish through the same safe-list every rendering passes (semantic tags survive; scripts, styles, and event handlers never become part of legal evidence). A page that only exists at runtime — an ERB view, a CMS entry — publishes through a `resolver:`, a callable that hands over the exact bytes at publish time:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
Clickwrap.document :terms,
|
|
79
|
+
version: "2026-08-15",
|
|
80
|
+
media_type: "text/html",
|
|
81
|
+
resolver: ->(definition) { ApplicationController.render(template: "legal/terms", layout: false) },
|
|
82
|
+
link: "/legal/terms"
|
|
83
|
+
```
|
|
84
|
+
|
|
75
85
|
#### Reading that front matter yourself: `Clickwrap::FrontMatter`
|
|
76
86
|
|
|
77
87
|
Your own pages usually need the same two answers, and it is the same block, so use the same reader rather than writing a third one:
|
|
@@ -146,7 +156,7 @@ Add the gem and run the installer:
|
|
|
146
156
|
|
|
147
157
|
```ruby
|
|
148
158
|
# Gemfile
|
|
149
|
-
gem "clickwrap"
|
|
159
|
+
gem "clickwrap"
|
|
150
160
|
```
|
|
151
161
|
|
|
152
162
|
```bash
|
|
@@ -319,6 +329,8 @@ other render.
|
|
|
319
329
|
|
|
320
330
|
## How it works
|
|
321
331
|
|
|
332
|
+
Most Rails apps treat "user agreed" as `accepted_terms_at` plus a checkbox. That's enough until you have to answer: which exact text, which version, what the button said, whether the box started unchecked, and whether the action happened without that evidence.
|
|
333
|
+
|
|
322
334
|
Most apps eventually accumulate an `accepted_terms_at` column, a `terms_version` string, a few hidden form fields, an `after_create` callback, and some IP columns. Each part looks reasonable alone. Together they produce partial writes, client-owned policy decisions, mutable history, and evidence only the original engineer can explain.
|
|
323
335
|
|
|
324
336
|
`clickwrap` replaces that plumbing with one coherent primitive:
|
|
@@ -706,9 +718,43 @@ Golden fixtures make a verifier regression for any released receipt schema fail
|
|
|
706
718
|
|
|
707
719
|
With the engine mounted, users can view and download their own receipts, and operator access is always host-authorized. Read the [receipts and verification guide](guides/receipts-and-verification.md) for exports, bundles, and what each verification tier does and doesn't establish.
|
|
708
720
|
|
|
709
|
-
## Request evidence
|
|
721
|
+
## Request evidence: record it — it's what cements the act to a person
|
|
710
722
|
|
|
711
|
-
`clickwrap` always records its event ID, server time, capture channel, and policy version.
|
|
723
|
+
`clickwrap` always records its event ID, server time, capture channel, and policy version. That proves **what was offered and what came back**. But an agreement dispute is rarely about the words — it's "that wasn't me" — and the answer to that is request evidence: the IP address, the browser, and where in the world the request came from, bound into the same digest-linked record at the same instant. Years later, "this exact sentence was accepted from this address, on this client, from this city, at this second, in the transaction that created the account" is a different conversation from "the row says yes". **Our recommendation is to record IP + user agent + geolocation on every assent policy** — you already hold a purpose (defending the very agreement being made) and the evidence lives encrypted, in its own annex, deletable on its own schedule if your counsel ever decides so.
|
|
724
|
+
|
|
725
|
+
What the gem refuses to do is turn it on *silently*. Every field is a separate named decision with a written purpose — there is deliberately no `maximum_evidence` switch — so the recommended posture is three explicit blocks in your initializer:
|
|
726
|
+
|
|
727
|
+
```ruby
|
|
728
|
+
# clickwrap-doc-test: syntax-only — the resolver needs trackdown installed
|
|
729
|
+
Clickwrap.configure do |config|
|
|
730
|
+
config.record_ip_address_by_default = true
|
|
731
|
+
config.reason_for_recording_ip_addresses_by_default =
|
|
732
|
+
"Corroborate who performed each recorded act, to defend the agreement itself"
|
|
733
|
+
config.keep_recorded_ip_addresses_indefinitely!(
|
|
734
|
+
because: "Corroboration must live exactly as long as the evidence it corroborates")
|
|
735
|
+
|
|
736
|
+
config.record_browser_user_agent_by_default = true
|
|
737
|
+
config.reason_for_recording_browser_user_agents_by_default =
|
|
738
|
+
"Corroborate the client context of each recorded act"
|
|
739
|
+
config.keep_recorded_browser_user_agents_indefinitely!(
|
|
740
|
+
because: "Corroboration must live exactly as long as the evidence it corroborates")
|
|
741
|
+
|
|
742
|
+
config.record_ip_geolocation_country_by_default = true
|
|
743
|
+
config.record_ip_geolocation_region_by_default = true
|
|
744
|
+
config.record_ip_geolocation_city_by_default = true
|
|
745
|
+
config.reason_for_recording_ip_geolocation_by_default =
|
|
746
|
+
"Corroborate where each recorded act was performed from"
|
|
747
|
+
config.keep_recorded_ip_geolocation_indefinitely!(
|
|
748
|
+
because: "Corroboration must live exactly as long as the evidence it corroborates")
|
|
749
|
+
config.ip_geolocation_resolver = Clickwrap::IpGeolocation::TrackdownResolver.new
|
|
750
|
+
|
|
751
|
+
config.review_default_request_evidence_configuration_on = Date.new(2027, 8, 1)
|
|
752
|
+
end
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
(`keep_recorded_..._indefinitely!` matches the retention default since 0.2.0 — evidence keeps until deletion is an explicit reviewed act. A corroboration that expires before the agreement it corroborates is a scheduled weakening of the record; if your counsel wants a clock instead, `delete_recorded_..._after` is the same one-line decision in the other direction.)
|
|
756
|
+
|
|
757
|
+
A single regulated surface can also name a field per policy instead of by default:
|
|
712
758
|
|
|
713
759
|
```ruby
|
|
714
760
|
Clickwrap.policy :regulated_authorization do
|
|
@@ -752,13 +798,17 @@ The [request evidence guide](guides/request-evidence.md) covers every field, the
|
|
|
752
798
|
|
|
753
799
|
## Retention, deletion, and legal holds
|
|
754
800
|
|
|
755
|
-
|
|
801
|
+
**By default, evidence is kept indefinitely.** A policy that never says `retain_with` runs under the built-in `evidence_kept_indefinitely` class: no deletion clock on the core event, none on any request evidence. The direction is deliberate — keeping is reversible (a reviewed disposition can always run later), deletion is not, and the day contractual evidence matters is usually years past every convenient schedule. Deletion is the explicit, opt-in decision:
|
|
756
802
|
|
|
757
803
|
```ruby
|
|
758
804
|
Clickwrap.retention :ordinary_agreement_evidence do
|
|
759
|
-
|
|
760
|
-
delete_recorded_ip_address_after
|
|
761
|
-
delete_recorded_browser_user_agent_after
|
|
805
|
+
retain_core_event_indefinitely # the default, said out loud
|
|
806
|
+
delete_recorded_ip_address_after 6.years
|
|
807
|
+
delete_recorded_browser_user_agent_after 6.years
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
Clickwrap.retention :short_lived_marketing_evidence do
|
|
811
|
+
retain_core_event_for 6.years # a reviewed deletion schedule, opt-in
|
|
762
812
|
end
|
|
763
813
|
```
|
|
764
814
|
|
|
@@ -228,10 +228,14 @@ module Clickwrap
|
|
|
228
228
|
@encrypt_recorded_ip_geolocation = true
|
|
229
229
|
|
|
230
230
|
# nil means "every policy that enables the field must supply its own
|
|
231
|
-
# rule"
|
|
231
|
+
# rule" — or, for by-default recording, that the host has said
|
|
232
|
+
# `keep_recorded_..._indefinitely!(because: "…")` out loud. Keeping
|
|
233
|
+
# forever is never silent: it is either the per-policy retention class's
|
|
234
|
+
# explicit business, or a named, reasoned sentence in the initializer.
|
|
232
235
|
@delete_recorded_ip_addresses_after = nil
|
|
233
236
|
@delete_recorded_browser_user_agents_after = nil
|
|
234
237
|
@delete_recorded_ip_geolocation_after = nil
|
|
238
|
+
@keep_recorded_request_evidence_indefinitely = {}
|
|
235
239
|
|
|
236
240
|
# Rails' request.remote_ip is the conventional reader. The host remains
|
|
237
241
|
# responsible for configuring and testing trusted proxies correctly:
|
|
@@ -895,13 +899,22 @@ module Clickwrap
|
|
|
895
899
|
"application's reviewed, present-tense reason, or turn that default off."
|
|
896
900
|
end
|
|
897
901
|
|
|
898
|
-
|
|
902
|
+
if delete_after.present? && keeps_recorded_request_evidence_indefinitely?(category)
|
|
903
|
+
raise ConfigurationError,
|
|
904
|
+
"Clickwrap is told both to delete recorded #{category} after " \
|
|
905
|
+
"#{delete_after.inspect} and to keep it indefinitely. Those are opposite " \
|
|
906
|
+
"decisions — keep exactly one."
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
next if delete_after.present? || keeps_recorded_request_evidence_indefinitely?(category)
|
|
899
910
|
|
|
900
911
|
raise ConfigurationError,
|
|
901
|
-
"Clickwrap is set to record #{category} for every policy by default, but " \
|
|
902
|
-
"
|
|
903
|
-
"
|
|
904
|
-
"
|
|
912
|
+
"Clickwrap is set to record #{category} for every policy by default, but nothing " \
|
|
913
|
+
"says how long to keep it. Either set a reviewed period with " \
|
|
914
|
+
"`delete_recorded_#{plural_for(category)}_after`, or keep it as long as the " \
|
|
915
|
+
"evidence it corroborates with " \
|
|
916
|
+
"`keep_recorded_#{plural_for(category)}_indefinitely!(because: \"…\")` — or turn " \
|
|
917
|
+
"the default off and let each policy choose its own retention rule."
|
|
905
918
|
end
|
|
906
919
|
end
|
|
907
920
|
|
|
@@ -913,6 +926,16 @@ module Clickwrap
|
|
|
913
926
|
end
|
|
914
927
|
end
|
|
915
928
|
|
|
929
|
+
def declare_indefinite_request_evidence!(category, because)
|
|
930
|
+
if because.to_s.strip.empty?
|
|
931
|
+
raise ConfigurationError,
|
|
932
|
+
"keep_recorded_#{plural_for(category)}_indefinitely! needs a `because:` " \
|
|
933
|
+
"explaining the reviewed decision."
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
@keep_recorded_request_evidence_indefinitely[category] = because
|
|
937
|
+
end
|
|
938
|
+
|
|
916
939
|
def validate_trusted_proxy_configuration!
|
|
917
940
|
records_ip_derived_evidence =
|
|
918
941
|
record_ip_address_by_default || enabled_default_ip_geolocation_fields.any?
|
|
@@ -1098,6 +1121,32 @@ module Clickwrap
|
|
|
1098
1121
|
# The deliberate, named escape hatch referenced by `ensure_encryption_choice`.
|
|
1099
1122
|
# It exists so that turning encryption off is a sentence a reviewer can find
|
|
1100
1123
|
# in a diff, with the host's own reason attached, rather than a `false`.
|
|
1124
|
+
# The named escape hatch for by-default request evidence with no deletion
|
|
1125
|
+
# clock: request evidence exists to corroborate evidence that (since 0.2.0)
|
|
1126
|
+
# keeps indefinitely by default, and a corroboration that expires before
|
|
1127
|
+
# the thing it corroborates is a scheduled weakening of the record. Same
|
|
1128
|
+
# rule as every escape hatch here: keeping forever must be a sentence a
|
|
1129
|
+
# reviewer can find in a diff, with the host's own reason attached.
|
|
1130
|
+
def keep_recorded_ip_addresses_indefinitely!(because:)
|
|
1131
|
+
declare_indefinite_request_evidence!(:ip_address, because)
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
def keep_recorded_browser_user_agents_indefinitely!(because:)
|
|
1135
|
+
declare_indefinite_request_evidence!(:browser_user_agent, because)
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
def keep_recorded_ip_geolocation_indefinitely!(because:)
|
|
1139
|
+
declare_indefinite_request_evidence!(:ip_geolocation, because)
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
def keeps_recorded_request_evidence_indefinitely?(category)
|
|
1143
|
+
@keep_recorded_request_evidence_indefinitely.key?(category.to_sym)
|
|
1144
|
+
end
|
|
1145
|
+
|
|
1146
|
+
def reason_for_keeping_recorded_request_evidence_indefinitely(category)
|
|
1147
|
+
@keep_recorded_request_evidence_indefinitely[category.to_sym]
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1101
1150
|
def deliberately_store_request_evidence_unencrypted!(because:)
|
|
1102
1151
|
if because.to_s.strip.empty?
|
|
1103
1152
|
raise ConfigurationError,
|
|
@@ -27,6 +27,13 @@ module Clickwrap
|
|
|
27
27
|
assign_rule!(:core_event, duration:)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# The default, said out loud. Omitting the core-event rule means the same
|
|
31
|
+
# thing, but a retention class somebody will read in review is better off
|
|
32
|
+
# carrying the decision in words.
|
|
33
|
+
def retain_core_event_indefinitely
|
|
34
|
+
assign_rule!(:core_event, indefinite: true)
|
|
35
|
+
end
|
|
36
|
+
|
|
30
37
|
# For obligations a duration cannot express — "five years, or three years
|
|
31
38
|
# after this contract is liquidated, whichever is later". The named
|
|
32
39
|
# calculation is registered by the host on the configuration object, and
|
|
@@ -62,11 +69,26 @@ module Clickwrap
|
|
|
62
69
|
assign_rule!(:ip_geolocation, host_event_name:)
|
|
63
70
|
end
|
|
64
71
|
|
|
72
|
+
# Keeping the annex as long as the core event it corroborates, said in
|
|
73
|
+
# the retention class itself. A corroboration that expires before the
|
|
74
|
+
# evidence it corroborates is a scheduled weakening of the record.
|
|
75
|
+
def keep_recorded_ip_address_indefinitely
|
|
76
|
+
assign_rule!(:ip_address, indefinite: true)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def keep_recorded_browser_user_agent_indefinitely
|
|
80
|
+
assign_rule!(:browser_user_agent, indefinite: true)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def keep_recorded_ip_geolocation_indefinitely
|
|
84
|
+
assign_rule!(:ip_geolocation, indefinite: true)
|
|
85
|
+
end
|
|
86
|
+
|
|
65
87
|
def compile = RetentionClass.new(key: @key, rules: @rules)
|
|
66
88
|
|
|
67
89
|
private
|
|
68
90
|
|
|
69
|
-
def assign_rule!(part, duration: nil, host_event_name: nil)
|
|
91
|
+
def assign_rule!(part, duration: nil, host_event_name: nil, indefinite: false)
|
|
70
92
|
if @rules.key?(part)
|
|
71
93
|
raise DefinitionError,
|
|
72
94
|
"Retention class #{@key} declares #{part} more than once. Keep one reviewed " \
|
|
@@ -74,7 +96,7 @@ module Clickwrap
|
|
|
74
96
|
"deletion deadline."
|
|
75
97
|
end
|
|
76
98
|
|
|
77
|
-
@rules[part] = RetentionClass::Rule.new(part:, duration:, host_event_name:)
|
|
99
|
+
@rules[part] = RetentionClass::Rule.new(part:, duration:, host_event_name:, indefinite:)
|
|
78
100
|
end
|
|
79
101
|
|
|
80
102
|
def method_missing(name, *_arguments, **_options)
|
|
@@ -557,7 +557,9 @@ module Clickwrap
|
|
|
557
557
|
return if retain_core_event_until.present? || retention_rule_name.present?
|
|
558
558
|
|
|
559
559
|
rule = Clickwrap.retention_class!(retention_class_key).rule_for(:core_event)
|
|
560
|
-
|
|
560
|
+
# An indefinite rule freezes nothing: no deadline, no named calculation.
|
|
561
|
+
# The blank schedule plus the class key IS the recorded decision.
|
|
562
|
+
return if rule.nil? || rule.indefinite?
|
|
561
563
|
|
|
562
564
|
if rule.duration?
|
|
563
565
|
self.retain_core_event_until = recorded_at_by_server + rule.duration
|
data/lib/clickwrap/policy.rb
CHANGED
|
@@ -29,7 +29,10 @@ module Clickwrap
|
|
|
29
29
|
authority_rule: nil, options: {})
|
|
30
30
|
@key = key.to_s
|
|
31
31
|
@statements = statements.freeze
|
|
32
|
-
|
|
32
|
+
# A policy that never says `retain_with` keeps its evidence indefinitely
|
|
33
|
+
# under the built-in class — the reversible default. Deletion clocks are
|
|
34
|
+
# the reviewed opt-in, declared with `Clickwrap.retention` and named here.
|
|
35
|
+
@retention_class_key = (retention_class_key || DEFAULT_RETENTION_CLASS_KEY).to_s
|
|
33
36
|
@request_evidence = request_evidence || RequestEvidencePolicy.new(policy_key: @key)
|
|
34
37
|
@persist_presentations_for = persist_presentations_for
|
|
35
38
|
@persist_presentations_because = persist_presentations_because
|
|
@@ -238,12 +241,10 @@ module Clickwrap
|
|
|
238
241
|
end
|
|
239
242
|
|
|
240
243
|
def validate_retention!
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
"that class with `Clickwrap.retention`. Clickwrap will not default your evidence " \
|
|
246
|
-
"to forever, and it will not pick a period for you."
|
|
244
|
+
# Always present: the initializer defaults a silent policy to the
|
|
245
|
+
# built-in evidence_kept_indefinitely class. Kept as a method so the
|
|
246
|
+
# validation order below still reads as the full checklist.
|
|
247
|
+
raise DefinitionError, "Policy #{key} has no retention class." if retention_class_key.blank?
|
|
247
248
|
end
|
|
248
249
|
|
|
249
250
|
def validate_persisted_presentations!
|
data/lib/clickwrap/privacy.rb
CHANGED
|
@@ -220,6 +220,7 @@ module Clickwrap
|
|
|
220
220
|
# difference between "not due yet" and "nothing can ever say when".
|
|
221
221
|
def describe_rule(rule)
|
|
222
222
|
return nil if rule.nil?
|
|
223
|
+
return { "kind" => "indefinite" } if rule.indefinite?
|
|
223
224
|
return { "kind" => "duration", "seconds" => rule.duration.to_i } if rule.duration?
|
|
224
225
|
|
|
225
226
|
{
|
data/lib/clickwrap/registry.rb
CHANGED
|
@@ -9,11 +9,18 @@ module Clickwrap
|
|
|
9
9
|
# loading the declaration files again. Seeing the same key twice inside one
|
|
10
10
|
# load is therefore always ambiguous and is refused instead of letting file
|
|
11
11
|
# order silently decide which policy governs a production action.
|
|
12
|
+
#
|
|
13
|
+
# A registry may carry a seed: built-in entries that are its floor rather
|
|
14
|
+
# than its contents. Clearing re-runs the seed, so a reload returns to the
|
|
15
|
+
# built-ins, never to nothing — which is what lets a gem-shipped default
|
|
16
|
+
# (the indefinite retention class) survive every `to_prepare`.
|
|
12
17
|
class Registry
|
|
13
|
-
def initialize(kind)
|
|
18
|
+
def initialize(kind, &seed)
|
|
14
19
|
@kind = kind
|
|
15
20
|
@entries = {}
|
|
16
21
|
@mutex = Mutex.new
|
|
22
|
+
@seed = seed
|
|
23
|
+
@seed&.call(self)
|
|
17
24
|
end
|
|
18
25
|
|
|
19
26
|
attr_reader :kind
|
|
@@ -47,7 +54,12 @@ module Clickwrap
|
|
|
47
54
|
def size = @entries.size
|
|
48
55
|
def empty? = @entries.empty?
|
|
49
56
|
def each(&) = @entries.each_value(&)
|
|
50
|
-
|
|
57
|
+
|
|
58
|
+
def clear
|
|
59
|
+
@mutex.synchronize { @entries.clear }
|
|
60
|
+
@seed&.call(self)
|
|
61
|
+
self
|
|
62
|
+
end
|
|
51
63
|
|
|
52
64
|
include Enumerable
|
|
53
65
|
end
|
|
@@ -483,10 +483,14 @@ module Clickwrap
|
|
|
483
483
|
|
|
484
484
|
# --- Retention ------------------------------------------------------------
|
|
485
485
|
|
|
486
|
-
# Every recorded field leaves here with a disposal
|
|
487
|
-
# of a host rule that will produce one
|
|
488
|
-
#
|
|
489
|
-
#
|
|
486
|
+
# Every recorded field leaves here with a disposal answer: a date, the name
|
|
487
|
+
# of a host rule that will produce one, or the explicit reviewed decision
|
|
488
|
+
# to keep it as long as the evidence it corroborates. Keeping forever is
|
|
489
|
+
# never a silent default — it is a named declaration in the retention class
|
|
490
|
+
# (`keep_recorded_..._indefinitely`) or the initializer
|
|
491
|
+
# (`keep_recorded_..._indefinitely!(because: "…")`) — and a recorded field
|
|
492
|
+
# with no answer at all is a configuration bug caught before the row is
|
|
493
|
+
# written rather than a row nobody ever decided about.
|
|
490
494
|
#
|
|
491
495
|
# `retain_until` names a host calculation instead of a duration because real
|
|
492
496
|
# record-keeping schedules are not always durations — "five years, or three
|
|
@@ -500,6 +504,14 @@ module Clickwrap
|
|
|
500
504
|
return { "#{category}_delete_after": now + class_rule.duration } if class_rule&.duration?
|
|
501
505
|
return { "#{category}_retain_until_rule": class_rule.host_event_name.to_s } if class_rule&.host_event?
|
|
502
506
|
|
|
507
|
+
# Indefinite — declared in the class or application-wide — stamps
|
|
508
|
+
# nothing: the blank schedule plus the recorded declaration IS the
|
|
509
|
+
# disposal answer, exactly like an indefinite core event.
|
|
510
|
+
if class_rule&.indefinite? ||
|
|
511
|
+
Clickwrap.config.keeps_recorded_request_evidence_indefinitely?(category)
|
|
512
|
+
return {}
|
|
513
|
+
end
|
|
514
|
+
|
|
503
515
|
raise ConfigurationError, missing_retention_message(category)
|
|
504
516
|
end
|
|
505
517
|
|
|
@@ -510,11 +522,13 @@ module Clickwrap
|
|
|
510
522
|
end
|
|
511
523
|
|
|
512
524
|
def missing_retention_message(category)
|
|
513
|
-
"Clickwrap is about to record #{category} for policy #{policy_key} and nothing says
|
|
514
|
-
"to
|
|
515
|
-
"`retain_until:` naming a host retention calculation —
|
|
516
|
-
"
|
|
517
|
-
"
|
|
525
|
+
"Clickwrap is about to record #{category} for policy #{policy_key} and nothing says what " \
|
|
526
|
+
"should ever happen to it. Give the policy a rule — `delete_after:` with a reviewed " \
|
|
527
|
+
"period, or `retain_until:` naming a host retention calculation — add a #{category} " \
|
|
528
|
+
"rule (or `keep_recorded_#{category}_indefinitely`) to retention class " \
|
|
529
|
+
"#{policy.retention_class_key.inspect}, or answer it application-wide with " \
|
|
530
|
+
"`keep_recorded_..._indefinitely!(because: \"…\")`. Keeping forever is never silent, " \
|
|
531
|
+
"and Clickwrap will not choose for you."
|
|
518
532
|
end
|
|
519
533
|
|
|
520
534
|
# --- Failing closed -------------------------------------------------------
|
|
@@ -205,12 +205,15 @@ module Clickwrap
|
|
|
205
205
|
"as a data-collection purpose."
|
|
206
206
|
end
|
|
207
207
|
|
|
208
|
-
if setting.delete_after.nil? && setting.retain_until.nil? && retention_class_key.nil?
|
|
208
|
+
if setting.delete_after.nil? && setting.retain_until.nil? && retention_class_key.nil? &&
|
|
209
|
+
!Clickwrap.config.keeps_recorded_request_evidence_indefinitely?(category)
|
|
209
210
|
raise DefinitionError,
|
|
210
|
-
"Policy #{policy_key} records #{category} but
|
|
211
|
-
"Give it `delete_after:` with a duration, or `retain_until:`
|
|
212
|
-
"rule,
|
|
213
|
-
"
|
|
211
|
+
"Policy #{policy_key} records #{category} but nothing says what should ever " \
|
|
212
|
+
"happen to it. Give it `delete_after:` with a duration, or `retain_until:` " \
|
|
213
|
+
"naming a host event rule, attach a retention class with a rule for this " \
|
|
214
|
+
"category, or answer it application-wide with " \
|
|
215
|
+
"`keep_recorded_..._indefinitely!(because: \"…\")`. Keeping forever is never " \
|
|
216
|
+
"silent, and Clickwrap will not choose for you."
|
|
214
217
|
end
|
|
215
218
|
|
|
216
219
|
return unless setting.delete_after && setting.delete_after.to_i <= 0
|
|
@@ -131,6 +131,15 @@ module Clickwrap
|
|
|
131
131
|
|
|
132
132
|
rule = retention_class.rule_for(:core_event)
|
|
133
133
|
return Eligibility.new(rule: nil, unresolved_reason: "No core-event rule is defined.") if rule.nil?
|
|
134
|
+
if rule.indefinite?
|
|
135
|
+
# Never due, by design — the reason names the decision, not a gap.
|
|
136
|
+
# (Scopes skip indefinite classes, so this branch is the answer for
|
|
137
|
+
# anyone asking about one event directly, and a guard for the
|
|
138
|
+
# applier's re-check.)
|
|
139
|
+
return Eligibility.new(rule: "indefinite",
|
|
140
|
+
unresolved_reason: "This event's retention class keeps the core " \
|
|
141
|
+
"event indefinitely; it is never due.")
|
|
142
|
+
end
|
|
134
143
|
return resolve_host_event(rule.host_event_name, event) if rule.host_event?
|
|
135
144
|
|
|
136
145
|
Eligibility.new(eligible_at: event.recorded_at_by_server + rule.duration,
|
|
@@ -249,7 +258,7 @@ module Clickwrap
|
|
|
249
258
|
|
|
250
259
|
Clickwrap.retention_classes.each do |retention_class|
|
|
251
260
|
rule = retention_class.rule_for(:core_event)
|
|
252
|
-
next if rule.nil?
|
|
261
|
+
next if rule.nil? || rule.indefinite?
|
|
253
262
|
|
|
254
263
|
unscheduled = base_events.where(retain_core_event_until: nil, retention_class_key: retention_class.key)
|
|
255
264
|
|
|
@@ -5,14 +5,19 @@ module Clickwrap
|
|
|
5
5
|
# kept, and what triggers the clock.
|
|
6
6
|
#
|
|
7
7
|
# Clickwrap.retention :ordinary_agreement_evidence do
|
|
8
|
-
#
|
|
9
|
-
# delete_recorded_ip_address_after
|
|
10
|
-
# delete_recorded_browser_user_agent_after
|
|
11
|
-
# delete_recorded_ip_geolocation_after 90.days
|
|
8
|
+
# retain_core_event_indefinitely
|
|
9
|
+
# delete_recorded_ip_address_after 6.years
|
|
10
|
+
# delete_recorded_browser_user_agent_after 6.years
|
|
12
11
|
# end
|
|
13
12
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# The default — for any part not given a rule, the core event included — is
|
|
14
|
+
# to keep the evidence indefinitely. That direction is deliberate: keeping is
|
|
15
|
+
# reversible (a reviewed disposition can always run later) while deletion is
|
|
16
|
+
# not, and the day contractual evidence matters is usually years away.
|
|
17
|
+
# Deletion is therefore the explicit, reviewed act, never a default.
|
|
18
|
+
#
|
|
19
|
+
# Clickwrap does not choose deletion periods and cannot tell you whether
|
|
20
|
+
# yours are right. What it does is make a reviewed decision executable and
|
|
16
21
|
# auditable, keep the core event's schedule separate from the optional
|
|
17
22
|
# personal request evidence, and delegate event-based or "later of" rules to
|
|
18
23
|
# a named host calculation. The host owns that calculation because a fixed
|
|
@@ -21,19 +26,24 @@ module Clickwrap
|
|
|
21
26
|
class RetentionClass
|
|
22
27
|
PARTS = %i[core_event ip_address browser_user_agent ip_geolocation].freeze
|
|
23
28
|
|
|
24
|
-
# A rule is
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
Rule = Data.define(:part, :duration, :host_event_name) do
|
|
28
|
-
def initialize(part:, duration: nil, host_event_name: nil)
|
|
29
|
+
# A rule is a duration from the event's server-recorded time, the name of a
|
|
30
|
+
# host-registered calculation that may depend on domain state and may not
|
|
31
|
+
# be resolvable yet — or the explicit decision to keep the part forever.
|
|
32
|
+
Rule = Data.define(:part, :duration, :host_event_name, :indefinite) do
|
|
33
|
+
def initialize(part:, duration: nil, host_event_name: nil, indefinite: false)
|
|
29
34
|
super
|
|
30
35
|
end
|
|
31
36
|
|
|
32
37
|
def duration? = !duration.nil?
|
|
33
38
|
def host_event? = !host_event_name.nil?
|
|
39
|
+
def indefinite? = indefinite
|
|
34
40
|
|
|
35
41
|
def to_snapshot
|
|
36
|
-
{
|
|
42
|
+
{
|
|
43
|
+
"duration_seconds" => duration&.to_i,
|
|
44
|
+
"host_event" => host_event_name&.to_s,
|
|
45
|
+
"indefinite" => (true if indefinite)
|
|
46
|
+
}.compact
|
|
37
47
|
end
|
|
38
48
|
end
|
|
39
49
|
|
|
@@ -41,6 +51,12 @@ module Clickwrap
|
|
|
41
51
|
|
|
42
52
|
def initialize(key:, rules:)
|
|
43
53
|
@key = key.to_s
|
|
54
|
+
# A part with no declared rule is kept indefinitely. For the core event
|
|
55
|
+
# that default is made explicit here, so every consumer — the planner,
|
|
56
|
+
# the privacy inventory, the snapshot on a plan — sees a reviewed answer
|
|
57
|
+
# ("indefinite") rather than a silence it must interpret.
|
|
58
|
+
rules = rules.dup
|
|
59
|
+
rules[:core_event] ||= Rule.new(part: :core_event, indefinite: true)
|
|
44
60
|
@rules = rules.freeze
|
|
45
61
|
|
|
46
62
|
validate!
|
|
@@ -67,10 +83,11 @@ module Clickwrap
|
|
|
67
83
|
end
|
|
68
84
|
|
|
69
85
|
rules.each_value do |rule|
|
|
70
|
-
if rule.duration
|
|
86
|
+
if [rule.duration?, rule.host_event?, rule.indefinite?].count(true) != 1
|
|
71
87
|
raise DefinitionError,
|
|
72
88
|
"Retention class #{key} must give #{rule.part} exactly one schedule: a " \
|
|
73
|
-
"duration
|
|
89
|
+
"duration, a named host calculation, or indefinite — never a combination " \
|
|
90
|
+
"and never none."
|
|
74
91
|
end
|
|
75
92
|
|
|
76
93
|
if rule.host_event? && rule.host_event_name.to_s.strip.empty?
|
|
@@ -85,13 +102,6 @@ module Clickwrap
|
|
|
85
102
|
"Retention class #{key} keeps #{rule.part} for #{rule.duration.inspect}, which is " \
|
|
86
103
|
"not a period."
|
|
87
104
|
end
|
|
88
|
-
|
|
89
|
-
return if rules.key?(:core_event)
|
|
90
|
-
|
|
91
|
-
raise DefinitionError,
|
|
92
|
-
"Retention class #{key} never says how long to keep the core event. Use " \
|
|
93
|
-
"`retain_core_event_for 6.years` or `retain_core_event_until :your_host_event`. " \
|
|
94
|
-
"Clickwrap has no forever default, and it will not pick a period for you."
|
|
95
105
|
end
|
|
96
106
|
end
|
|
97
107
|
end
|
|
@@ -110,15 +110,32 @@ module Clickwrap
|
|
|
110
110
|
setting = policy.request_evidence.setting_for(category)
|
|
111
111
|
next unless setting.record?
|
|
112
112
|
next if setting.delete_after || setting.retain_until || retention_class.rule_for(category)
|
|
113
|
+
# The application-wide answer counts too: recording enabled in the
|
|
114
|
+
# initializer carries its disposal decision in the same place —
|
|
115
|
+
# either a global clock or the explicit, reasoned keep-indefinitely.
|
|
116
|
+
next if config_answers_disposal_for?(category)
|
|
113
117
|
|
|
114
118
|
raise DefinitionError,
|
|
115
|
-
"Policy #{policy.key} records #{category}, but
|
|
116
|
-
"
|
|
117
|
-
"
|
|
118
|
-
"
|
|
119
|
+
"Policy #{policy.key} records #{category}, but nothing says when to dispose of " \
|
|
120
|
+
"it. Add `delete_after:`/`retain_until:` to the policy, a plain-English " \
|
|
121
|
+
"request-evidence rule (or `keep_recorded_#{category}_indefinitely`) to " \
|
|
122
|
+
"retention class #{retention_class.key}, or answer it application-wide in the " \
|
|
123
|
+
"initializer with `delete_recorded_..._after` or " \
|
|
124
|
+
"`keep_recorded_..._indefinitely!(because: \"…\")`."
|
|
119
125
|
end
|
|
120
126
|
end
|
|
121
127
|
|
|
128
|
+
def config_answers_disposal_for?(category)
|
|
129
|
+
clock =
|
|
130
|
+
case category.to_sym
|
|
131
|
+
when :ip_address then Clickwrap.config.delete_recorded_ip_addresses_after
|
|
132
|
+
when :browser_user_agent then Clickwrap.config.delete_recorded_browser_user_agents_after
|
|
133
|
+
else Clickwrap.config.delete_recorded_ip_geolocation_after
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
clock.present? || Clickwrap.config.keeps_recorded_request_evidence_indefinitely?(category)
|
|
137
|
+
end
|
|
138
|
+
|
|
122
139
|
def validate_host_calculations!(policy, retention_class)
|
|
123
140
|
referenced = retention_class.rules.values.filter_map do |rule|
|
|
124
141
|
rule.host_event_name&.to_sym
|
data/lib/clickwrap/version.rb
CHANGED
data/lib/clickwrap.rb
CHANGED
|
@@ -58,6 +58,10 @@ require_relative "clickwrap/engine" if defined?(Rails::Engine)
|
|
|
58
58
|
# long you must keep anything. Those belong to the application and its counsel,
|
|
59
59
|
# and no configuration flag here can stand in for them.
|
|
60
60
|
module Clickwrap
|
|
61
|
+
# The retention class every policy gets unless it names its own with
|
|
62
|
+
# `retain_with`: evidence kept indefinitely, deletion always an explicit,
|
|
63
|
+
# reviewed act. Keeping is reversible; deleting is not.
|
|
64
|
+
DEFAULT_RETENTION_CLASS_KEY = "evidence_kept_indefinitely"
|
|
61
65
|
DOCUMENT_OPTIONS = %i[
|
|
62
66
|
version locale media_type effective_at tenant from content resolver renderer link
|
|
63
67
|
].freeze
|
|
@@ -109,7 +113,19 @@ module Clickwrap
|
|
|
109
113
|
|
|
110
114
|
def documents = @documents ||= Registry.new(:document)
|
|
111
115
|
def policies = @policies ||= Registry.new(:policy)
|
|
112
|
-
|
|
116
|
+
|
|
117
|
+
# The registry is seeded with one built-in class: evidence kept
|
|
118
|
+
# indefinitely, nothing scheduled for deletion. It exists so a policy that
|
|
119
|
+
# never says `retain_with` has a real, inspectable retention class instead
|
|
120
|
+
# of a hole — keeping is the reversible default; deletion is the reviewed
|
|
121
|
+
# opt-in. A host wanting deletion clocks declares its own class and names
|
|
122
|
+
# it on the policy. The seed survives every reload (see Registry#clear).
|
|
123
|
+
def retention_classes
|
|
124
|
+
@retention_classes ||= Registry.new(:retention_class) do |registry|
|
|
125
|
+
registry.register(DEFAULT_RETENTION_CLASS_KEY,
|
|
126
|
+
RetentionClass.new(key: DEFAULT_RETENTION_CLASS_KEY, rules: {}))
|
|
127
|
+
end
|
|
128
|
+
end
|
|
113
129
|
|
|
114
130
|
# Declares one immutable document version. Declaring it does not publish it:
|
|
115
131
|
# `bin/rails clickwrap:publish` reads the bytes once, digests them, and
|