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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 55 additions & 6 deletions lib/clickwrap/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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?
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions lib/clickwrap/dsl/retention_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 23 additions & 9 deletions lib/clickwrap/request_evidence_extractor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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 -------------------------------------------------------
Expand Down
13 changes: 8 additions & 5 deletions lib/clickwrap/request_evidence_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 21 additions & 4 deletions lib/clickwrap/services/validate_policy_references.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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.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
Expand Down
Loading
Loading