Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.2.0] - 2026-08-19

### Changed — evidence is kept indefinitely by default

- **The default retention posture is now indefinite.** A policy that never
says `retain_with` runs under a new built-in retention class,
`evidence_kept_indefinitely`: no deletion clock on the core event, none on
any request evidence. Previously such a policy refused to boot. The
direction is deliberate: keeping is reversible — a reviewed disposition can
always run later — while deletion is not, and the day contractual evidence
matters is usually years past every convenient schedule. Deletion is the
explicit, reviewed opt-in it always was: declare a class with clocks and
name it on the policy.
- **A retention class may keep the core event forever.** New DSL verb
`retain_core_event_indefinitely` says the default out loud; omitting the
core-event rule now means the same thing instead of raising. Snapshots
record `{"indefinite" => true}`, the privacy inventory reports
`{"kind" => "indefinite"}`, events under such a class freeze no deadline,
and the retention planner never lists them as due — on any horizon.
- **Registries can carry built-in seeds.** `Registry#clear` (every reload)
now returns a seeded registry to its built-ins instead of to nothing, which
is what keeps the default retention class alive across `to_prepare`.

### Documentation

- The README installs from rubygems.org (`gem "clickwrap"`), documents the
new retention default, and shows HTML pages and runtime `resolver:` sources
for legal documents alongside Markdown.

## [0.1.1] - 2026-08-19

### Fixed — the composed sentence in a language that declines its articles
Expand Down
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ Clickwrap.document :handbook,
from: Rails.root.join("app/content/legal/handbook.pdf")
```

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:

```ruby
Clickwrap.document :terms,
version: "2026-08-15",
media_type: "text/html",
resolver: ->(definition) { ApplicationController.render(template: "legal/terms", layout: false) },
link: "/legal/terms"
```

#### Reading that front matter yourself: `Clickwrap::FrontMatter`

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:
Expand Down Expand Up @@ -146,7 +156,7 @@ Add the gem and run the installer:

```ruby
# Gemfile
gem "clickwrap", github: "rameerez/clickwrap"
gem "clickwrap"
```

```bash
Expand Down Expand Up @@ -754,13 +764,17 @@ The [request evidence guide](guides/request-evidence.md) covers every field, the

## Retention, deletion, and legal holds

Every policy chooses an application-defined retention class:
**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:

```ruby
Clickwrap.retention :ordinary_agreement_evidence do
retain_core_event_for 6.years
delete_recorded_ip_address_after 90.days
delete_recorded_browser_user_agent_after 90.days
retain_core_event_indefinitely # the default, said out loud
delete_recorded_ip_address_after 6.years
delete_recorded_browser_user_agent_after 6.years
end

Clickwrap.retention :short_lived_marketing_evidence do
retain_core_event_for 6.years # a reviewed deletion schedule, opt-in
end
```

Expand Down
18 changes: 17 additions & 1 deletion lib/clickwrap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
# long you must keep anything. Those belong to the application and its counsel,
# and no configuration flag here can stand in for them.
module Clickwrap
# The retention class every policy gets unless it names its own with
# `retain_with`: evidence kept indefinitely, deletion always an explicit,
# reviewed act. Keeping is reversible; deleting is not.
DEFAULT_RETENTION_CLASS_KEY = "evidence_kept_indefinitely"
DOCUMENT_OPTIONS = %i[
version locale media_type effective_at tenant from content resolver renderer link
].freeze
Expand Down Expand Up @@ -109,7 +113,19 @@ def reset!

def documents = @documents ||= Registry.new(:document)
def policies = @policies ||= Registry.new(:policy)
def retention_classes = @retention_classes ||= Registry.new(:retention_class)

# The registry is seeded with one built-in class: evidence kept
# indefinitely, nothing scheduled for deletion. It exists so a policy that
# never says `retain_with` has a real, inspectable retention class instead
# of a hole — keeping is the reversible default; deletion is the reviewed
# opt-in. A host wanting deletion clocks declares its own class and names
# it on the policy. The seed survives every reload (see Registry#clear).
def retention_classes
@retention_classes ||= Registry.new(:retention_class) do |registry|
registry.register(DEFAULT_RETENTION_CLASS_KEY,
RetentionClass.new(key: DEFAULT_RETENTION_CLASS_KEY, rules: {}))
end
end

# Declares one immutable document version. Declaring it does not publish it:
# `bin/rails clickwrap:publish` reads the bytes once, digests them, and
Expand Down
11 changes: 9 additions & 2 deletions lib/clickwrap/dsl/retention_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ def retain_core_event_for(duration)
assign_rule!(:core_event, duration:)
end

# The default, said out loud. Omitting the core-event rule means the same
# thing, but a retention class somebody will read in review is better off
# carrying the decision in words.
def retain_core_event_indefinitely
assign_rule!(:core_event, indefinite: true)
end

# For obligations a duration cannot express — "five years, or three years
# after this contract is liquidated, whichever is later". The named
# calculation is registered by the host on the configuration object, and
Expand Down Expand Up @@ -66,15 +73,15 @@ def compile = RetentionClass.new(key: @key, rules: @rules)

private

def assign_rule!(part, duration: nil, host_event_name: nil)
def assign_rule!(part, duration: nil, host_event_name: nil, indefinite: false)
if @rules.key?(part)
raise DefinitionError,
"Retention class #{@key} declares #{part} more than once. Keep one reviewed " \
"rule for each part; Clickwrap will not let line order silently replace a " \
"deletion deadline."
end

@rules[part] = RetentionClass::Rule.new(part:, duration:, host_event_name:)
@rules[part] = RetentionClass::Rule.new(part:, duration:, host_event_name:, indefinite:)
end

def method_missing(name, *_arguments, **_options)
Expand Down
4 changes: 3 additions & 1 deletion lib/clickwrap/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,9 @@ def assign_retention_schedule_from_class
return if retain_core_event_until.present? || retention_rule_name.present?

rule = Clickwrap.retention_class!(retention_class_key).rule_for(:core_event)
return if rule.nil?
# An indefinite rule freezes nothing: no deadline, no named calculation.
# The blank schedule plus the class key IS the recorded decision.
return if rule.nil? || rule.indefinite?

if rule.duration?
self.retain_core_event_until = recorded_at_by_server + rule.duration
Expand Down
15 changes: 8 additions & 7 deletions lib/clickwrap/policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ def initialize(key:, statements:, retention_class_key: nil, request_evidence: ni
authority_rule: nil, options: {})
@key = key.to_s
@statements = statements.freeze
@retention_class_key = retention_class_key&.to_s
# A policy that never says `retain_with` keeps its evidence indefinitely
# under the built-in class — the reversible default. Deletion clocks are
# the reviewed opt-in, declared with `Clickwrap.retention` and named here.
@retention_class_key = (retention_class_key || DEFAULT_RETENTION_CLASS_KEY).to_s
@request_evidence = request_evidence || RequestEvidencePolicy.new(policy_key: @key)
@persist_presentations_for = persist_presentations_for
@persist_presentations_because = persist_presentations_because
Expand Down Expand Up @@ -238,12 +241,10 @@ def validate_prerequisites!
end

def validate_retention!
return if retention_class_key

raise DefinitionError,
"Policy #{key} has no retention class. Add `retain_with :some_class` and define " \
"that class with `Clickwrap.retention`. Clickwrap will not default your evidence " \
"to forever, and it will not pick a period for you."
# Always present: the initializer defaults a silent policy to the
# built-in evidence_kept_indefinitely class. Kept as a method so the
# validation order below still reads as the full checklist.
raise DefinitionError, "Policy #{key} has no retention class." if retention_class_key.blank?
end

def validate_persisted_presentations!
Expand Down
1 change: 1 addition & 0 deletions lib/clickwrap/privacy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ def retention_inventory(retention_class)
# difference between "not due yet" and "nothing can ever say when".
def describe_rule(rule)
return nil if rule.nil?
return { "kind" => "indefinite" } if rule.indefinite?
return { "kind" => "duration", "seconds" => rule.duration.to_i } if rule.duration?

{
Expand Down
16 changes: 14 additions & 2 deletions lib/clickwrap/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@ module Clickwrap
# loading the declaration files again. Seeing the same key twice inside one
# load is therefore always ambiguous and is refused instead of letting file
# order silently decide which policy governs a production action.
#
# A registry may carry a seed: built-in entries that are its floor rather
# than its contents. Clearing re-runs the seed, so a reload returns to the
# built-ins, never to nothing — which is what lets a gem-shipped default
# (the indefinite retention class) survive every `to_prepare`.
class Registry
def initialize(kind)
def initialize(kind, &seed)
@kind = kind
@entries = {}
@mutex = Mutex.new
@seed = seed
@seed&.call(self)
end

attr_reader :kind
Expand Down Expand Up @@ -47,7 +54,12 @@ def values = @entries.values
def size = @entries.size
def empty? = @entries.empty?
def each(&) = @entries.each_value(&)
def clear = @mutex.synchronize { @entries.clear }

def clear
@mutex.synchronize { @entries.clear }
@seed&.call(self)
self
end

include Enumerable
end
Expand Down
11 changes: 10 additions & 1 deletion lib/clickwrap/retention/planner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ def core_event_eligibility(event)

rule = retention_class.rule_for(:core_event)
return Eligibility.new(rule: nil, unresolved_reason: "No core-event rule is defined.") if rule.nil?
if rule.indefinite?
# Never due, by design — the reason names the decision, not a gap.
# (Scopes skip indefinite classes, so this branch is the answer for
# anyone asking about one event directly, and a guard for the
# applier's re-check.)
return Eligibility.new(rule: "indefinite",
unresolved_reason: "This event's retention class keeps the core " \
"event indefinitely; it is never due.")
end
return resolve_host_event(rule.host_event_name, event) if rule.host_event?

Eligibility.new(eligible_at: event.recorded_at_by_server + rule.duration,
Expand Down Expand Up @@ -249,7 +258,7 @@ def core_event_scopes

Clickwrap.retention_classes.each do |retention_class|
rule = retention_class.rule_for(:core_event)
next if rule.nil?
next if rule.nil? || rule.indefinite?

unscheduled = base_events.where(retain_core_event_until: nil, retention_class_key: retention_class.key)

Expand Down
52 changes: 31 additions & 21 deletions lib/clickwrap/retention_class.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ module Clickwrap
# kept, and what triggers the clock.
#
# Clickwrap.retention :ordinary_agreement_evidence do
# retain_core_event_for 6.years
# delete_recorded_ip_address_after 90.days
# delete_recorded_browser_user_agent_after 90.days
# delete_recorded_ip_geolocation_after 90.days
# retain_core_event_indefinitely
# delete_recorded_ip_address_after 6.years
# delete_recorded_browser_user_agent_after 6.years
# end
#
# Clickwrap does not choose these periods and cannot tell you whether yours
# are right. What it does is make a reviewed decision executable and
# The default — for any part not given a rule, the core event included — is
# to keep the evidence indefinitely. That direction is deliberate: keeping is
# reversible (a reviewed disposition can always run later) while deletion is
# not, and the day contractual evidence matters is usually years away.
# Deletion is therefore the explicit, reviewed act, never a default.
#
# Clickwrap does not choose deletion periods and cannot tell you whether
# yours are right. What it does is make a reviewed decision executable and
# auditable, keep the core event's schedule separate from the optional
# personal request evidence, and delegate event-based or "later of" rules to
# a named host calculation. The host owns that calculation because a fixed
Expand All @@ -21,26 +26,37 @@ module Clickwrap
class RetentionClass
PARTS = %i[core_event ip_address browser_user_agent ip_geolocation].freeze

# A rule is either a duration from the event's server-recorded time, or the
# name of a host-registered calculation that may depend on domain state and
# may not be resolvable yet.
Rule = Data.define(:part, :duration, :host_event_name) do
def initialize(part:, duration: nil, host_event_name: nil)
# A rule is a duration from the event's server-recorded time, the name of a
# host-registered calculation that may depend on domain state and may not
# be resolvable yet — or the explicit decision to keep the part forever.
Rule = Data.define(:part, :duration, :host_event_name, :indefinite) do
def initialize(part:, duration: nil, host_event_name: nil, indefinite: false)
super
end

def duration? = !duration.nil?
def host_event? = !host_event_name.nil?
def indefinite? = indefinite

def to_snapshot
{ "duration_seconds" => duration&.to_i, "host_event" => host_event_name&.to_s }.compact
{
"duration_seconds" => duration&.to_i,
"host_event" => host_event_name&.to_s,
"indefinite" => (true if indefinite)
}.compact
end
end

attr_reader :key, :rules

def initialize(key:, rules:)
@key = key.to_s
# A part with no declared rule is kept indefinitely. For the core event
# that default is made explicit here, so every consumer — the planner,
# the privacy inventory, the snapshot on a plan — sees a reviewed answer
# ("indefinite") rather than a silence it must interpret.
rules = rules.dup
rules[:core_event] ||= Rule.new(part: :core_event, indefinite: true)
@rules = rules.freeze

validate!
Expand All @@ -67,10 +83,11 @@ def validate!
end

rules.each_value do |rule|
if rule.duration? == rule.host_event?
if [rule.duration?, rule.host_event?, rule.indefinite?].count(true) != 1
raise DefinitionError,
"Retention class #{key} must give #{rule.part} exactly one schedule: a " \
"duration or a named host calculation, not both or neither."
"duration, a named host calculation, or indefinite — never a combination " \
"and never none."
end

if rule.host_event? && rule.host_event_name.to_s.strip.empty?
Expand All @@ -85,13 +102,6 @@ def validate!
"Retention class #{key} keeps #{rule.part} for #{rule.duration.inspect}, which is " \
"not a period."
end

return if rules.key?(:core_event)

raise DefinitionError,
"Retention class #{key} never says how long to keep the core event. Use " \
"`retain_core_event_for 6.years` or `retain_core_event_until :your_host_event`. " \
"Clickwrap has no forever default, and it will not pick a period for you."
end
end
end
2 changes: 1 addition & 1 deletion lib/clickwrap/version.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

module Clickwrap
VERSION = "0.1.1"
VERSION = "0.2.0"

# The canonical schema version for receipts, event digests, and presentation
# manifests. This is deliberately independent of VERSION: gem releases may
Expand Down
Loading
Loading