clickwrap 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +21 -5
- data/lib/clickwrap/dsl/retention_builder.rb +9 -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/retention/planner.rb +10 -1
- data/lib/clickwrap/retention_class.rb +31 -21
- 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: 601c5b3f678c4b70f6a9b5466230a445f41bbb6c944a3479ce60ef252f189768
|
|
4
|
+
data.tar.gz: 8137ed6901d3b33f93b18d1300ecf548eec2bb5e90cb59410d7614060e80d73d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6313bfced4a79586e71d32ee58c4e216a6a7f96b1e0910fb066f877bc3c0465fb0408bcf1d5690f630472f92584f27209ed87a1c686bae20dae1116b2fd654cc
|
|
7
|
+
data.tar.gz: c950b0d74657b7b9965b00a7cd6274bbe62ce5fb827e20a6d076980a4b168f19a215fc3f3900a817422cc036e57698f7ef5ae77fbcdc76a7f41793421889dd66
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.0] - 2026-08-19
|
|
10
|
+
|
|
11
|
+
### Changed — evidence is kept indefinitely by default
|
|
12
|
+
|
|
13
|
+
- **The default retention posture is now indefinite.** A policy that never
|
|
14
|
+
says `retain_with` runs under a new built-in retention class,
|
|
15
|
+
`evidence_kept_indefinitely`: no deletion clock on the core event, none on
|
|
16
|
+
any request evidence. Previously such a policy refused to boot. The
|
|
17
|
+
direction is deliberate: keeping is reversible — a reviewed disposition can
|
|
18
|
+
always run later — while deletion is not, and the day contractual evidence
|
|
19
|
+
matters is usually years past every convenient schedule. Deletion is the
|
|
20
|
+
explicit, reviewed opt-in it always was: declare a class with clocks and
|
|
21
|
+
name it on the policy.
|
|
22
|
+
- **A retention class may keep the core event forever.** New DSL verb
|
|
23
|
+
`retain_core_event_indefinitely` says the default out loud; omitting the
|
|
24
|
+
core-event rule now means the same thing instead of raising. Snapshots
|
|
25
|
+
record `{"indefinite" => true}`, the privacy inventory reports
|
|
26
|
+
`{"kind" => "indefinite"}`, events under such a class freeze no deadline,
|
|
27
|
+
and the retention planner never lists them as due — on any horizon.
|
|
28
|
+
- **Registries can carry built-in seeds.** `Registry#clear` (every reload)
|
|
29
|
+
now returns a seeded registry to its built-ins instead of to nothing, which
|
|
30
|
+
is what keeps the default retention class alive across `to_prepare`.
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
- The README installs from rubygems.org (`gem "clickwrap"`), documents the
|
|
35
|
+
new retention default, and shows HTML pages and runtime `resolver:` sources
|
|
36
|
+
for legal documents alongside Markdown.
|
|
37
|
+
|
|
9
38
|
## [0.1.1] - 2026-08-19
|
|
10
39
|
|
|
11
40
|
### 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:
|
|
@@ -752,13 +764,17 @@ The [request evidence guide](guides/request-evidence.md) covers every field, the
|
|
|
752
764
|
|
|
753
765
|
## Retention, deletion, and legal holds
|
|
754
766
|
|
|
755
|
-
|
|
767
|
+
**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
768
|
|
|
757
769
|
```ruby
|
|
758
770
|
Clickwrap.retention :ordinary_agreement_evidence do
|
|
759
|
-
|
|
760
|
-
delete_recorded_ip_address_after
|
|
761
|
-
delete_recorded_browser_user_agent_after
|
|
771
|
+
retain_core_event_indefinitely # the default, said out loud
|
|
772
|
+
delete_recorded_ip_address_after 6.years
|
|
773
|
+
delete_recorded_browser_user_agent_after 6.years
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
Clickwrap.retention :short_lived_marketing_evidence do
|
|
777
|
+
retain_core_event_for 6.years # a reviewed deletion schedule, opt-in
|
|
762
778
|
end
|
|
763
779
|
```
|
|
764
780
|
|
|
@@ -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
|
|
@@ -66,7 +73,7 @@ module Clickwrap
|
|
|
66
73
|
|
|
67
74
|
private
|
|
68
75
|
|
|
69
|
-
def assign_rule!(part, duration: nil, host_event_name: nil)
|
|
76
|
+
def assign_rule!(part, duration: nil, host_event_name: nil, indefinite: false)
|
|
70
77
|
if @rules.key?(part)
|
|
71
78
|
raise DefinitionError,
|
|
72
79
|
"Retention class #{@key} declares #{part} more than once. Keep one reviewed " \
|
|
@@ -74,7 +81,7 @@ module Clickwrap
|
|
|
74
81
|
"deletion deadline."
|
|
75
82
|
end
|
|
76
83
|
|
|
77
|
-
@rules[part] = RetentionClass::Rule.new(part:, duration:, host_event_name:)
|
|
84
|
+
@rules[part] = RetentionClass::Rule.new(part:, duration:, host_event_name:, indefinite:)
|
|
78
85
|
end
|
|
79
86
|
|
|
80
87
|
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
|
|
@@ -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
|
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
|