From 78064f4546b69f556abc6b483ea6291512b1c156 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:35:40 +0100 Subject: [PATCH] Evidence is kept indefinitely by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting contractual evidence on a convenient clock is the wrong default: the day it matters is usually years past every schedule, and of the two mistakes only one is reversible. Keeping can always become a reviewed disposition later; a deletion is forever. So the default flips: a policy that never says retain_with now runs under a built-in evidence_kept_indefinitely class — no deletion clock on the core event, none on request evidence — instead of refusing to boot. Deletion stays exactly what it was: an explicit, reviewed, opt-in decision. retain_core_event_indefinitely says the default out loud in a class of your own; omitting the core rule means the same thing. Snapshots record {"indefinite" => true}, the privacy inventory reports it as a decision rather than a gap, events freeze no deadline, and the planner never lists an indefinite event as due — not in ten years, not in a hundred (pinned by test at exactly that horizon). Registries now carry seeds: clear() returns a seeded registry to its built-ins, never to nothing, so the default class survives every to_prepare reload. README: install from rubygems.org, the new retention posture, and HTML pages / runtime resolver: sources for legal documents alongside Markdown. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013A6ZmfmFuQ2z3GyvQCuECA --- CHANGELOG.md | 29 ++++++++ README.md | 24 +++++-- lib/clickwrap.rb | 18 ++++- lib/clickwrap/dsl/retention_builder.rb | 11 ++- lib/clickwrap/models/event.rb | 4 +- lib/clickwrap/policy.rb | 15 ++-- lib/clickwrap/privacy.rb | 1 + lib/clickwrap/registry.rb | 16 ++++- lib/clickwrap/retention/planner.rb | 11 ++- lib/clickwrap/retention_class.rb | 52 ++++++++------ lib/clickwrap/version.rb | 2 +- test/retention_defaults_test.rb | 94 ++++++++++++++++++++++++++ 12 files changed, 236 insertions(+), 41 deletions(-) create mode 100644 test/retention_defaults_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 85d6b1f..544785a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4af3028..f609ab5 100644 --- a/README.md +++ b/README.md @@ -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: @@ -146,7 +156,7 @@ Add the gem and run the installer: ```ruby # Gemfile -gem "clickwrap", github: "rameerez/clickwrap" +gem "clickwrap" ``` ```bash @@ -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 ``` diff --git a/lib/clickwrap.rb b/lib/clickwrap.rb index cf26f92..0a66621 100644 --- a/lib/clickwrap.rb +++ b/lib/clickwrap.rb @@ -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 @@ -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 diff --git a/lib/clickwrap/dsl/retention_builder.rb b/lib/clickwrap/dsl/retention_builder.rb index 95e754a..2ec9404 100644 --- a/lib/clickwrap/dsl/retention_builder.rb +++ b/lib/clickwrap/dsl/retention_builder.rb @@ -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 @@ -66,7 +73,7 @@ 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 " \ @@ -74,7 +81,7 @@ def assign_rule!(part, duration: nil, host_event_name: nil) "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) diff --git a/lib/clickwrap/models/event.rb b/lib/clickwrap/models/event.rb index 3b85244..580de41 100644 --- a/lib/clickwrap/models/event.rb +++ b/lib/clickwrap/models/event.rb @@ -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 diff --git a/lib/clickwrap/policy.rb b/lib/clickwrap/policy.rb index e8f78a4..4b06ee6 100644 --- a/lib/clickwrap/policy.rb +++ b/lib/clickwrap/policy.rb @@ -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 @@ -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! diff --git a/lib/clickwrap/privacy.rb b/lib/clickwrap/privacy.rb index e06c3a3..1023cb5 100644 --- a/lib/clickwrap/privacy.rb +++ b/lib/clickwrap/privacy.rb @@ -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? { diff --git a/lib/clickwrap/registry.rb b/lib/clickwrap/registry.rb index c0d03bd..e2b812f 100644 --- a/lib/clickwrap/registry.rb +++ b/lib/clickwrap/registry.rb @@ -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 @@ -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 diff --git a/lib/clickwrap/retention/planner.rb b/lib/clickwrap/retention/planner.rb index 7b4d5a5..f0de5a6 100644 --- a/lib/clickwrap/retention/planner.rb +++ b/lib/clickwrap/retention/planner.rb @@ -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, @@ -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) diff --git a/lib/clickwrap/retention_class.rb b/lib/clickwrap/retention_class.rb index 911ea0e..83dd1a2 100644 --- a/lib/clickwrap/retention_class.rb +++ b/lib/clickwrap/retention_class.rb @@ -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 @@ -21,19 +26,24 @@ 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 @@ -41,6 +51,12 @@ def to_snapshot 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! @@ -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? @@ -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 diff --git a/lib/clickwrap/version.rb b/lib/clickwrap/version.rb index 972b8bc..1e89a4a 100644 --- a/lib/clickwrap/version.rb +++ b/lib/clickwrap/version.rb @@ -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 diff --git a/test/retention_defaults_test.rb b/test/retention_defaults_test.rb new file mode 100644 index 0000000..46cfb26 --- /dev/null +++ b/test/retention_defaults_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "test_helper" + +# The default retention posture: evidence is kept indefinitely unless the +# application makes the reviewed, explicit decision to schedule deletion. +# Direction matters — keeping is reversible (a disposition can always run +# later), deleting is not, and the day contractual evidence is needed is +# usually years past every convenient clock. So the built-in class +# `evidence_kept_indefinitely` backs any policy that never says `retain_with`, +# an omitted core-event rule means indefinite, and the planner never lists an +# indefinite event as due — not in ten years, not in a hundred. +class RetentionDefaultsTest < ActiveSupport::TestCase + setup do + @user = create_user + @operator = create_security_operator + end + + test "a policy that never says retain_with keeps its evidence under the built-in class" do + Clickwrap.policy :quiet_about_retention do + agree_to :terms, link_label: "Terms of Service" + end + + policy = Clickwrap.policy!(:quiet_about_retention) + assert_equal "evidence_kept_indefinitely", policy.retention_class_key + + built_in = Clickwrap.retention_class!("evidence_kept_indefinitely") + assert built_in.rule_for(:core_event).indefinite? + # Request evidence carries no deletion clock either: nothing under this + # class is ever scheduled away. + assert_nil built_in.rule_for(:ip_address) + end + + test "an event under the default class freezes no deadline and is never planned" do + Clickwrap.policy :quiet_about_retention do + agree_to :terms, link_label: "Terms of Service" + end + + receipt = submit_clickwrap(:quiet_about_retention, actor: @user) + event = receipt.event.reload + + # The blank schedule plus the class key IS the recorded decision. + assert_equal "evidence_kept_indefinitely", event.retention_class_key + assert_nil event.retain_core_event_until + assert_nil event.retention_rule_name + + travel_to 100.years.from_now do + planner = Clickwrap::Retention::Planner.new(created_by: @operator, because: "Scheduled retention run") + planner.call + + assert_empty planner.due_items.select { |item| item.event_id == receipt.event_id }, + "an indefinite event must never become due, on any horizon" + end + + # Asked directly, the answer names the decision rather than a deadline. + eligibility = Clickwrap::Retention::Planner.core_event_eligibility(event) + assert_equal "indefinite", eligibility.rule + assert_not eligibility.due?(Time.current + 100.years) + end + + test "omitting the core-event rule means indefinite, and saying so out loud means the same" do + omitted = Clickwrap.retention(:quiet_core) { delete_recorded_ip_address_after 6.years } + spoken = Clickwrap.retention(:spoken_core) { retain_core_event_indefinitely } + + assert omitted.rule_for(:core_event).indefinite? + assert spoken.rule_for(:core_event).indefinite? + assert_equal({ "indefinite" => true }, spoken.rule_for(:core_event).to_snapshot) + + # The deletion clock on the annex part survives beside the indefinite core. + assert_equal 6.years.to_i, omitted.rule_for(:ip_address).duration.to_i + end + + test "the privacy inventory reports an indefinite rule as a decision, not a gap" do + inventory = Clickwrap::Privacy.inventory + built_in = inventory["retention_classes"] + .find { |entry| entry["retention_class"] == "evidence_kept_indefinitely" } + + assert built_in, "the built-in class belongs in the privacy inventory" + assert_equal({ "kind" => "indefinite" }, built_in["rules"]["core_event"]) + end + + test "a scheduled class still schedules: indefinite is the default, never a ceiling" do + Clickwrap.retention(:short_lived_probe) { retain_core_event_for 6.years } + Clickwrap.policy :scheduled_probe do + agree_to :terms, link_label: "Terms of Service" + retain_with :short_lived_probe + end + + receipt = submit_clickwrap(:scheduled_probe, actor: @user) + event = receipt.event.reload + + assert_equal (event.recorded_at_by_server + 6.years).to_i, event.retain_core_event_until.to_i + end +end