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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f6304579666eb5ab8765fd22bb279e897ae328b166fe7f6d3ae5addcc61b61a
4
- data.tar.gz: 67be7cf559b23e5f85b14a37f0d72a343015ba2ac69d717b0951ef9e026d1199
3
+ metadata.gz: 601c5b3f678c4b70f6a9b5466230a445f41bbb6c944a3479ce60ef252f189768
4
+ data.tar.gz: 8137ed6901d3b33f93b18d1300ecf548eec2bb5e90cb59410d7614060e80d73d
5
5
  SHA512:
6
- metadata.gz: b382d32b2a7c4dbccb05a2700b06b334255016c0c6bd8883e20ee1129030e47fc68959dcfc5aeebddd73ba517b68da927ec460bbb64ca52e7f82188d6ebe450a
7
- data.tar.gz: 4b98d8d3d68a1d35d4497c1ebf6091de0fc2699ea382884495657f207b4f428b34a596d2adc7a1e95abf951e14287d1d0ab38a49e2bce4f9f2c06cfc5966e598
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", github: "rameerez/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
- Every policy chooses an application-defined retention class:
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
- retain_core_event_for 6.years
760
- delete_recorded_ip_address_after 90.days
761
- delete_recorded_browser_user_agent_after 90.days
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
- return if rule.nil?
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
@@ -29,7 +29,10 @@ module Clickwrap
29
29
  authority_rule: nil, options: {})
30
30
  @key = key.to_s
31
31
  @statements = statements.freeze
32
- @retention_class_key = retention_class_key&.to_s
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
- return if retention_class_key
242
-
243
- raise DefinitionError,
244
- "Policy #{key} has no retention class. Add `retain_with :some_class` and define " \
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!
@@ -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
  {
@@ -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
- def clear = @mutex.synchronize { @entries.clear }
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
- # retain_core_event_for 6.years
9
- # delete_recorded_ip_address_after 90.days
10
- # delete_recorded_browser_user_agent_after 90.days
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
- # Clickwrap does not choose these periods and cannot tell you whether yours
15
- # are right. What it does is make a reviewed decision executable and
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 either a duration from the event's server-recorded time, or the
25
- # name of a host-registered calculation that may depend on domain state and
26
- # may not be resolvable yet.
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
- { "duration_seconds" => duration&.to_i, "host_event" => host_event_name&.to_s }.compact
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? == rule.host_event?
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 or a named host calculation, not both or neither."
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clickwrap
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
 
6
6
  # The canonical schema version for receipts, event digests, and presentation
7
7
  # manifests. This is deliberately independent of VERSION: gem releases may
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
- def retention_classes = @retention_classes ||= Registry.new(:retention_class)
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clickwrap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez