From beafda65f5c1c5c7dff065991b001c03aef1c6c3 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:07:06 +0100 Subject: [PATCH 1/2] Request evidence can keep pace with the evidence it corroborates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By-default request evidence demanded a deletion clock. After 0.2.0 flipped core evidence to keep-indefinitely, that meant the corroboration — the IP, the client, the place that cement a recorded act to a person when the dispute is "that wasn't me" — was scheduled to expire before the agreement it corroborates. A scheduled weakening of the record is not a privacy feature. keep_recorded_{ip_addresses,browser_user_agents,ip_geolocation}_indefinitely!(because:) is the third answer to "how long", said out loud with a reason like every escape hatch here. A clock and keep-indefinitely together are refused as opposite decisions; keeping forever silently remains impossible. The README's request-evidence section now argues FOR recording, with the on-by-default posture as the recommendation — discipline unchanged: per-field decisions, written purposes, encryption, reviewed proxy provenance. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013A6ZmfmFuQ2z3GyvQCuECA --- CHANGELOG.md | 20 ++++++ README.md | 38 ++++++++++- lib/clickwrap/configuration.rb | 61 ++++++++++++++++-- lib/clickwrap/version.rb | 2 +- ...request_evidence_keep_indefinitely_test.rb | 64 +++++++++++++++++++ test/request_evidence_test.rb | 2 +- 6 files changed, 177 insertions(+), 10 deletions(-) create mode 100644 test/request_evidence_keep_indefinitely_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 544785a..f18b0a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.1] - 2026-08-19 + +### Added — request evidence can keep pace with the evidence it corroborates + +- **`keep_recorded_{ip_addresses,browser_user_agents,ip_geolocation}_indefinitely!(because:)`.** + By-default request evidence used to demand a deletion clock, which — after + 0.2.0 flipped core evidence to keep-indefinitely — scheduled the + corroboration (IP, user agent, geolocation) to expire before the agreement + it corroborates. The third option now exists and must be said out loud with + a reason, like every escape hatch here. Declaring both a clock and + keep-indefinitely is refused as opposite decisions. + +### Documentation + +- The request-evidence README section now argues FOR recording: IP + user + agent + geolocation are what cement a recorded act to a person when the + dispute is "that wasn't me", and the recommended posture is on-by-default + with the purpose written down. The discipline is unchanged — no silent + enablement, per-field decisions, encryption, reviewed proxy provenance. + ## [0.2.0] - 2026-08-19 ### Changed — evidence is kept indefinitely by default diff --git a/README.md b/README.md index f609ab5..3401646 100644 --- a/README.md +++ b/README.md @@ -718,9 +718,43 @@ Golden fixtures make a verifier regression for any released receipt schema fail 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. -## Request evidence is off by default +## Request evidence: record it — it's what cements the act to a person -`clickwrap` always records its event ID, server time, capture channel, and policy version. It records **no** IP addresses, browser user-agents, or IP geolocation unless a policy names the field with a purpose and a retention rule: +`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. + +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: + +```ruby +# clickwrap-doc-test: syntax-only — the resolver needs trackdown installed +Clickwrap.configure do |config| + config.record_ip_address_by_default = true + config.reason_for_recording_ip_addresses_by_default = + "Corroborate who performed each recorded act, to defend the agreement itself" + config.keep_recorded_ip_addresses_indefinitely!( + because: "Corroboration must live exactly as long as the evidence it corroborates") + + config.record_browser_user_agent_by_default = true + config.reason_for_recording_browser_user_agents_by_default = + "Corroborate the client context of each recorded act" + config.keep_recorded_browser_user_agents_indefinitely!( + because: "Corroboration must live exactly as long as the evidence it corroborates") + + config.record_ip_geolocation_country_by_default = true + config.record_ip_geolocation_region_by_default = true + config.record_ip_geolocation_city_by_default = true + config.reason_for_recording_ip_geolocation_by_default = + "Corroborate where each recorded act was performed from" + config.keep_recorded_ip_geolocation_indefinitely!( + because: "Corroboration must live exactly as long as the evidence it corroborates") + config.ip_geolocation_resolver = Clickwrap::IpGeolocation::TrackdownResolver.new + + config.review_default_request_evidence_configuration_on = Date.new(2027, 8, 1) +end +``` + +(`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.) + +A single regulated surface can also name a field per policy instead of by default: ```ruby Clickwrap.policy :regulated_authorization do diff --git a/lib/clickwrap/configuration.rb b/lib/clickwrap/configuration.rb index 40bb9bc..d1cad54 100644 --- a/lib/clickwrap/configuration.rb +++ b/lib/clickwrap/configuration.rb @@ -228,10 +228,14 @@ def initialize @encrypt_recorded_ip_geolocation = true # nil means "every policy that enables the field must supply its own - # rule". There is no keep-forever default anywhere in this gem. + # rule" — or, for by-default recording, that the host has said + # `keep_recorded_..._indefinitely!(because: "…")` out loud. Keeping + # forever is never silent: it is either the per-policy retention class's + # explicit business, or a named, reasoned sentence in the initializer. @delete_recorded_ip_addresses_after = nil @delete_recorded_browser_user_agents_after = nil @delete_recorded_ip_geolocation_after = nil + @keep_recorded_request_evidence_indefinitely = {} # Rails' request.remote_ip is the conventional reader. The host remains # responsible for configuring and testing trusted proxies correctly: @@ -895,13 +899,22 @@ def validate_request_evidence_defaults! "application's reviewed, present-tense reason, or turn that default off." end - next unless delete_after.nil? + if delete_after.present? && keeps_recorded_request_evidence_indefinitely?(category) + raise ConfigurationError, + "Clickwrap is told both to delete recorded #{category} after " \ + "#{delete_after.inspect} and to keep it indefinitely. Those are opposite " \ + "decisions — keep exactly one." + end + + next if delete_after.present? || keeps_recorded_request_evidence_indefinitely?(category) raise ConfigurationError, - "Clickwrap is set to record #{category} for every policy by default, but " \ - "`delete_recorded_#{plural_for(category)}_after` is nil, so nothing would ever " \ - "delete it. Set a reviewed period, or turn the default off and let each policy " \ - "choose its own retention rule." + "Clickwrap is set to record #{category} for every policy by default, but nothing " \ + "says how long to keep it. Either set a reviewed period with " \ + "`delete_recorded_#{plural_for(category)}_after`, or keep it as long as the " \ + "evidence it corroborates with " \ + "`keep_recorded_#{plural_for(category)}_indefinitely!(because: \"…\")` — or turn " \ + "the default off and let each policy choose its own retention rule." end end @@ -913,6 +926,16 @@ def plural_for(category) end end + def declare_indefinite_request_evidence!(category, because) + if because.to_s.strip.empty? + raise ConfigurationError, + "keep_recorded_#{plural_for(category)}_indefinitely! needs a `because:` " \ + "explaining the reviewed decision." + end + + @keep_recorded_request_evidence_indefinitely[category] = because + end + def validate_trusted_proxy_configuration! records_ip_derived_evidence = record_ip_address_by_default || enabled_default_ip_geolocation_fields.any? @@ -1098,6 +1121,32 @@ def ensure_positive_duration_or_nil(value, name) # The deliberate, named escape hatch referenced by `ensure_encryption_choice`. # It exists so that turning encryption off is a sentence a reviewer can find # in a diff, with the host's own reason attached, rather than a `false`. + # The named escape hatch for by-default request evidence with no deletion + # clock: request evidence exists to corroborate evidence that (since 0.2.0) + # keeps indefinitely by default, and a corroboration that expires before + # the thing it corroborates is a scheduled weakening of the record. Same + # rule as every escape hatch here: keeping forever must be a sentence a + # reviewer can find in a diff, with the host's own reason attached. + def keep_recorded_ip_addresses_indefinitely!(because:) + declare_indefinite_request_evidence!(:ip_address, because) + end + + def keep_recorded_browser_user_agents_indefinitely!(because:) + declare_indefinite_request_evidence!(:browser_user_agent, because) + end + + def keep_recorded_ip_geolocation_indefinitely!(because:) + declare_indefinite_request_evidence!(:ip_geolocation, because) + end + + def keeps_recorded_request_evidence_indefinitely?(category) + @keep_recorded_request_evidence_indefinitely.key?(category.to_sym) + end + + def reason_for_keeping_recorded_request_evidence_indefinitely(category) + @keep_recorded_request_evidence_indefinitely[category.to_sym] + end + def deliberately_store_request_evidence_unencrypted!(because:) if because.to_s.strip.empty? raise ConfigurationError, diff --git a/lib/clickwrap/version.rb b/lib/clickwrap/version.rb index 1e89a4a..b0ec76a 100644 --- a/lib/clickwrap/version.rb +++ b/lib/clickwrap/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Clickwrap - VERSION = "0.2.0" + VERSION = "0.2.1" # The canonical schema version for receipts, event digests, and presentation # manifests. This is deliberately independent of VERSION: gem releases may diff --git a/test/request_evidence_keep_indefinitely_test.rb b/test/request_evidence_keep_indefinitely_test.rb new file mode 100644 index 0000000..30b0d6a --- /dev/null +++ b/test/request_evidence_keep_indefinitely_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "test_helper" + +# By-default request evidence used to demand a deletion clock — which, since +# 0.2.0 flipped core evidence to keep-indefinitely, meant the corroboration +# (IP, user agent, geolocation) was scheduled to expire before the agreement +# it corroborates. These pin the third option: keeping it as long as the +# evidence itself, said out loud with a reason, never silently. +class RequestEvidenceKeepIndefinitelyTest < ActiveSupport::TestCase + def enable_ip_defaults!(config) + config.trusted_proxy_configuration_digest = + Clickwrap.trusted_proxy_configuration_digest_for([IPAddr.new("10.0.0.0/8")]) + config.record_ip_address_by_default = true + config.reason_for_recording_ip_addresses_by_default = "Corroborate who performed each act" + end + + test "an explicit keep-indefinitely satisfies the how-long question" do + Clickwrap.configure do |config| + enable_ip_defaults!(config) + config.keep_recorded_ip_addresses_indefinitely!( + because: "Corroboration lives as long as the evidence it corroborates" + ) + end + + assert Clickwrap.config.validate! + assert Clickwrap.config.keeps_recorded_request_evidence_indefinitely?(:ip_address) + assert_nil Clickwrap.config.delete_recorded_ip_addresses_after + end + + test "recording by default with neither a clock nor keep-indefinitely refuses to boot" do + error = assert_raises(Clickwrap::ConfigurationError) do + Clickwrap.configure { |config| enable_ip_defaults!(config) } + Clickwrap.config.validate! + end + + assert_match(/nothing says how long to keep it/, error.message) + assert_match(/keep_recorded_ip_addresses_indefinitely!/, error.message) + assert_match(/delete_recorded_ip_addresses_after/, error.message) + end + + test "a deletion clock and keep-indefinitely together are refused as opposite decisions" do + error = assert_raises(Clickwrap::ConfigurationError) do + Clickwrap.configure do |config| + enable_ip_defaults!(config) + config.delete_recorded_ip_addresses_after = 2.years + config.keep_recorded_ip_addresses_indefinitely!(because: "Also forever") + end + Clickwrap.config.validate! + end + + assert_match(/opposite decisions/, error.message) + end + + test "keep-indefinitely without a reason is refused" do + error = assert_raises(Clickwrap::ConfigurationError) do + Clickwrap.configure do |config| + config.keep_recorded_browser_user_agents_indefinitely!(because: " ") + end + end + + assert_match(/needs a `because:`/, error.message) + end +end diff --git a/test/request_evidence_test.rb b/test/request_evidence_test.rb index beaac84..23fd5ef 100644 --- a/test/request_evidence_test.rb +++ b/test/request_evidence_test.rb @@ -61,7 +61,7 @@ class RequestEvidenceTest < ActiveSupport::TestCase end end - assert_match(/nothing would ever\s+delete it/, error.message) + assert_match(/nothing says how long to keep it/, error.message) end test "enabling geolocation with no resolver fails at boot" do From b91a90e801370b38e9219cad4a1669d84b1a7c1a Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:15:36 +0100 Subject: [PATCH 2/2] Every disposal gatekeeper accepts the keep-indefinitely answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more places demanded a deletion clock for recorded request evidence and knew nothing of the new declaration: the per-policy reference validator, the policy-level setting validator, and the capture-time extractor that stamps the annex schedule. All three now accept the third answer — an indefinite annex rule in the retention class (new keep_recorded_{ip_address,browser_user_agent,ip_geolocation}_indefinitely builder verbs) or the application-wide keep_recorded_..._indefinitely!(because:) — and their error messages name all the options. Precedence is unchanged and more-specific wins: policy clock, then class rule, then the application-wide declaration. An indefinite annex stamps no schedule; the planner reports it unresolved-by-decision and never lists it, pinned at a 100-year horizon through a real capture. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013A6ZmfmFuQ2z3GyvQCuECA --- lib/clickwrap/dsl/retention_builder.rb | 15 ++++++ lib/clickwrap/request_evidence_extractor.rb | 32 +++++++---- lib/clickwrap/request_evidence_policy.rb | 13 +++-- .../services/validate_policy_references.rb | 25 +++++++-- ...request_evidence_keep_indefinitely_test.rb | 54 +++++++++++++++++++ 5 files changed, 121 insertions(+), 18 deletions(-) diff --git a/lib/clickwrap/dsl/retention_builder.rb b/lib/clickwrap/dsl/retention_builder.rb index 2ec9404..6b92317 100644 --- a/lib/clickwrap/dsl/retention_builder.rb +++ b/lib/clickwrap/dsl/retention_builder.rb @@ -69,6 +69,21 @@ def retain_recorded_ip_geolocation_until(host_event_name) assign_rule!(:ip_geolocation, host_event_name:) end + # Keeping the annex as long as the core event it corroborates, said in + # the retention class itself. A corroboration that expires before the + # evidence it corroborates is a scheduled weakening of the record. + def keep_recorded_ip_address_indefinitely + assign_rule!(:ip_address, indefinite: true) + end + + def keep_recorded_browser_user_agent_indefinitely + assign_rule!(:browser_user_agent, indefinite: true) + end + + def keep_recorded_ip_geolocation_indefinitely + assign_rule!(:ip_geolocation, indefinite: true) + end + def compile = RetentionClass.new(key: @key, rules: @rules) private diff --git a/lib/clickwrap/request_evidence_extractor.rb b/lib/clickwrap/request_evidence_extractor.rb index e0e4c7c..6835499 100644 --- a/lib/clickwrap/request_evidence_extractor.rb +++ b/lib/clickwrap/request_evidence_extractor.rb @@ -483,10 +483,14 @@ def accuracy_radius_values(location) # --- Retention ------------------------------------------------------------ - # Every recorded field leaves here with a disposal rule: a date, or the name - # of a host rule that will produce one. There is no keep-forever default - # anywhere in this gem, and a recorded field with neither is a configuration - # bug caught before the row is written rather than a row nobody ever deletes. + # Every recorded field leaves here with a disposal answer: a date, the name + # of a host rule that will produce one, or the explicit reviewed decision + # to keep it as long as the evidence it corroborates. Keeping forever is + # never a silent default — it is a named declaration in the retention class + # (`keep_recorded_..._indefinitely`) or the initializer + # (`keep_recorded_..._indefinitely!(because: "…")`) — and a recorded field + # with no answer at all is a configuration bug caught before the row is + # written rather than a row nobody ever decided about. # # `retain_until` names a host calculation instead of a duration because real # record-keeping schedules are not always durations — "five years, or three @@ -500,6 +504,14 @@ def retention_attributes(category, setting) return { "#{category}_delete_after": now + class_rule.duration } if class_rule&.duration? return { "#{category}_retain_until_rule": class_rule.host_event_name.to_s } if class_rule&.host_event? + # Indefinite — declared in the class or application-wide — stamps + # nothing: the blank schedule plus the recorded declaration IS the + # disposal answer, exactly like an indefinite core event. + if class_rule&.indefinite? || + Clickwrap.config.keeps_recorded_request_evidence_indefinitely?(category) + return {} + end + raise ConfigurationError, missing_retention_message(category) end @@ -510,11 +522,13 @@ def retention_class_rule_for(category) end def missing_retention_message(category) - "Clickwrap is about to record #{category} for policy #{policy_key} and nothing says when " \ - "to delete it. Give the policy a rule — `delete_after:` with a reviewed period, or " \ - "`retain_until:` naming a host retention calculation — or add a #{category} rule to " \ - "retention class #{policy.retention_class_key.inspect}. Clickwrap has no keep-forever " \ - "default and will not choose a period for you." + "Clickwrap is about to record #{category} for policy #{policy_key} and nothing says what " \ + "should ever happen to it. Give the policy a rule — `delete_after:` with a reviewed " \ + "period, or `retain_until:` naming a host retention calculation — add a #{category} " \ + "rule (or `keep_recorded_#{category}_indefinitely`) to retention class " \ + "#{policy.retention_class_key.inspect}, or answer it application-wide with " \ + "`keep_recorded_..._indefinitely!(because: \"…\")`. Keeping forever is never silent, " \ + "and Clickwrap will not choose for you." end # --- Failing closed ------------------------------------------------------- diff --git a/lib/clickwrap/request_evidence_policy.rb b/lib/clickwrap/request_evidence_policy.rb index e538f1e..dc4bb36 100644 --- a/lib/clickwrap/request_evidence_policy.rb +++ b/lib/clickwrap/request_evidence_policy.rb @@ -205,12 +205,15 @@ def validate_category!(category) "as a data-collection purpose." end - if setting.delete_after.nil? && setting.retain_until.nil? && retention_class_key.nil? + if setting.delete_after.nil? && setting.retain_until.nil? && retention_class_key.nil? && + !Clickwrap.config.keeps_recorded_request_evidence_indefinitely?(category) raise DefinitionError, - "Policy #{policy_key} records #{category} but never says when to delete it. " \ - "Give it `delete_after:` with a duration, or `retain_until:` naming a host event " \ - "rule, or attach a retention class with a rule for this category. Clickwrap has " \ - "no keep-forever default." + "Policy #{policy_key} records #{category} but nothing says what should ever " \ + "happen to it. Give it `delete_after:` with a duration, or `retain_until:` " \ + "naming a host event rule, attach a retention class with a rule for this " \ + "category, or answer it application-wide with " \ + "`keep_recorded_..._indefinitely!(because: \"…\")`. Keeping forever is never " \ + "silent, and Clickwrap will not choose for you." end return unless setting.delete_after && setting.delete_after.to_i <= 0 diff --git a/lib/clickwrap/services/validate_policy_references.rb b/lib/clickwrap/services/validate_policy_references.rb index b2f0f2a..a31fabb 100644 --- a/lib/clickwrap/services/validate_policy_references.rb +++ b/lib/clickwrap/services/validate_policy_references.rb @@ -110,15 +110,32 @@ def validate_request_evidence_retention!(policy, retention_class) setting = policy.request_evidence.setting_for(category) next unless setting.record? next if setting.delete_after || setting.retain_until || retention_class.rule_for(category) + # The application-wide answer counts too: recording enabled in the + # initializer carries its disposal decision in the same place — + # either a global clock or the explicit, reasoned keep-indefinitely. + next if config_answers_disposal_for?(category) raise DefinitionError, - "Policy #{policy.key} records #{category}, but neither that policy nor retention " \ - "class #{retention_class.key} says when to dispose of it. Add " \ - "`delete_after:`/`retain_until:` to the policy or the matching plain-English " \ - "request-evidence rule to the retention class." + "Policy #{policy.key} records #{category}, but nothing says when to dispose of " \ + "it. Add `delete_after:`/`retain_until:` to the policy, a plain-English " \ + "request-evidence rule (or `keep_recorded_#{category}_indefinitely`) to " \ + "retention class #{retention_class.key}, or answer it application-wide in the " \ + "initializer with `delete_recorded_..._after` or " \ + "`keep_recorded_..._indefinitely!(because: \"…\")`." end end + def config_answers_disposal_for?(category) + clock = + case category.to_sym + when :ip_address then Clickwrap.config.delete_recorded_ip_addresses_after + when :browser_user_agent then Clickwrap.config.delete_recorded_browser_user_agents_after + else Clickwrap.config.delete_recorded_ip_geolocation_after + end + + clock.present? || Clickwrap.config.keeps_recorded_request_evidence_indefinitely?(category) + end + def validate_host_calculations!(policy, retention_class) referenced = retention_class.rules.values.filter_map do |rule| rule.host_event_name&.to_sym diff --git a/test/request_evidence_keep_indefinitely_test.rb b/test/request_evidence_keep_indefinitely_test.rb index 30b0d6a..3329525 100644 --- a/test/request_evidence_keep_indefinitely_test.rb +++ b/test/request_evidence_keep_indefinitely_test.rb @@ -61,4 +61,58 @@ def enable_ip_defaults!(config) assert_match(/needs a `because:`/, error.message) end + test "by-default recording under keep-indefinitely compiles, captures, and is never planned" do + Clickwrap.configure do |config| + enable_ip_defaults!(config) + config.keep_recorded_ip_addresses_indefinitely!( + because: "Corroboration lives as long as the evidence it corroborates" + ) + end + # A policy with no retain_with runs under the built-in indefinite class, + # which carries no annex clocks — so the initializer's declaration is the + # standing answer. (A retention class's own annex rule, where one exists, + # rightly outranks it: more specific wins.) + Clickwrap.policy :keep_pace_probe do + agree_to :terms, link_label: "Terms of Service" + end + + user = create_user + receipt = submit_clickwrap(:keep_pace_probe, actor: user, http_request: fake_http_request) + annex = receipt.event.reload.request_evidence + + assert annex.present?, "the capture carries a request-evidence annex" + assert_equal "203.0.113.7", annex.ip_address + # The blank schedule plus the recorded declaration IS the disposal answer. + assert_nil annex.ip_address_delete_after + assert_nil annex.ip_address_retain_until_rule + + travel_to 100.years.from_now do + planner = Clickwrap::Retention::Planner.new( + created_by: create_security_operator, because: "Scheduled retention run" + ) + planner.call + annex_items = planner.due_items.select { |item| item.event_id == receipt.event_id && item.part == :ip_address } + assert_empty annex_items, "indefinite request evidence must never become due" + end + end + + test "a retention class may keep an annex part indefinitely, in its own words" do + retention = Clickwrap.retention(:corroboration_keeps_pace) do + retain_core_event_indefinitely + keep_recorded_ip_address_indefinitely + end + + assert retention.rule_for(:ip_address).indefinite? + assert_equal({ "indefinite" => true }, retention.rule_for(:ip_address).to_snapshot) + end + + private + + def fake_http_request + ActionDispatch::TestRequest.create( + "REMOTE_ADDR" => "203.0.113.7", + "HTTP_USER_AGENT" => "Mozilla/5.0 (Macintosh) Test/1.0", + "action_dispatch.request_id" => "req-#{SecureRandom.hex(4)}" + ) + end end