clickwrap 0.2.0 → 0.2.1
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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +36 -2
- data/lib/clickwrap/configuration.rb +55 -6
- data/lib/clickwrap/dsl/retention_builder.rb +15 -0
- data/lib/clickwrap/request_evidence_extractor.rb +23 -9
- data/lib/clickwrap/request_evidence_policy.rb +8 -5
- data/lib/clickwrap/services/validate_policy_references.rb +21 -4
- data/lib/clickwrap/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be18f8fc09eee066b71a234a134440010c6cf2beb82987b4bbe3636dde2f9133
|
|
4
|
+
data.tar.gz: a2c66a9be09cf999de30d890c4338e084146414cadbe0747022a3aedc8a4ef5f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 891f939a6b827915a5d2703ecf98b0ed9b9e761d3689a1117e8ba3df6ae0450c50a3f9d53edd0cbf844e996a7be447b080070b6ab2d916409b5d1ecfdc851ddc
|
|
7
|
+
data.tar.gz: 2387391b3d960d05dcdc783221bb5653de21406ecd0173b8fbafa1dc17d716549044a22bc719740892553b8a9c66beb8b42417c61e9bc7f585d569849fa43d9d
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.1] - 2026-08-19
|
|
10
|
+
|
|
11
|
+
### Added — request evidence can keep pace with the evidence it corroborates
|
|
12
|
+
|
|
13
|
+
- **`keep_recorded_{ip_addresses,browser_user_agents,ip_geolocation}_indefinitely!(because:)`.**
|
|
14
|
+
By-default request evidence used to demand a deletion clock, which — after
|
|
15
|
+
0.2.0 flipped core evidence to keep-indefinitely — scheduled the
|
|
16
|
+
corroboration (IP, user agent, geolocation) to expire before the agreement
|
|
17
|
+
it corroborates. The third option now exists and must be said out loud with
|
|
18
|
+
a reason, like every escape hatch here. Declaring both a clock and
|
|
19
|
+
keep-indefinitely is refused as opposite decisions.
|
|
20
|
+
|
|
21
|
+
### Documentation
|
|
22
|
+
|
|
23
|
+
- The request-evidence README section now argues FOR recording: IP + user
|
|
24
|
+
agent + geolocation are what cement a recorded act to a person when the
|
|
25
|
+
dispute is "that wasn't me", and the recommended posture is on-by-default
|
|
26
|
+
with the purpose written down. The discipline is unchanged — no silent
|
|
27
|
+
enablement, per-field decisions, encryption, reviewed proxy provenance.
|
|
28
|
+
|
|
9
29
|
## [0.2.0] - 2026-08-19
|
|
10
30
|
|
|
11
31
|
### Changed — evidence is kept indefinitely by default
|
data/README.md
CHANGED
|
@@ -718,9 +718,43 @@ Golden fixtures make a verifier regression for any released receipt schema fail
|
|
|
718
718
|
|
|
719
719
|
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.
|
|
720
720
|
|
|
721
|
-
## Request evidence
|
|
721
|
+
## Request evidence: record it — it's what cements the act to a person
|
|
722
722
|
|
|
723
|
-
`clickwrap` always records its event ID, server time, capture channel, and policy version.
|
|
723
|
+
`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.
|
|
724
|
+
|
|
725
|
+
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:
|
|
726
|
+
|
|
727
|
+
```ruby
|
|
728
|
+
# clickwrap-doc-test: syntax-only — the resolver needs trackdown installed
|
|
729
|
+
Clickwrap.configure do |config|
|
|
730
|
+
config.record_ip_address_by_default = true
|
|
731
|
+
config.reason_for_recording_ip_addresses_by_default =
|
|
732
|
+
"Corroborate who performed each recorded act, to defend the agreement itself"
|
|
733
|
+
config.keep_recorded_ip_addresses_indefinitely!(
|
|
734
|
+
because: "Corroboration must live exactly as long as the evidence it corroborates")
|
|
735
|
+
|
|
736
|
+
config.record_browser_user_agent_by_default = true
|
|
737
|
+
config.reason_for_recording_browser_user_agents_by_default =
|
|
738
|
+
"Corroborate the client context of each recorded act"
|
|
739
|
+
config.keep_recorded_browser_user_agents_indefinitely!(
|
|
740
|
+
because: "Corroboration must live exactly as long as the evidence it corroborates")
|
|
741
|
+
|
|
742
|
+
config.record_ip_geolocation_country_by_default = true
|
|
743
|
+
config.record_ip_geolocation_region_by_default = true
|
|
744
|
+
config.record_ip_geolocation_city_by_default = true
|
|
745
|
+
config.reason_for_recording_ip_geolocation_by_default =
|
|
746
|
+
"Corroborate where each recorded act was performed from"
|
|
747
|
+
config.keep_recorded_ip_geolocation_indefinitely!(
|
|
748
|
+
because: "Corroboration must live exactly as long as the evidence it corroborates")
|
|
749
|
+
config.ip_geolocation_resolver = Clickwrap::IpGeolocation::TrackdownResolver.new
|
|
750
|
+
|
|
751
|
+
config.review_default_request_evidence_configuration_on = Date.new(2027, 8, 1)
|
|
752
|
+
end
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
(`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.)
|
|
756
|
+
|
|
757
|
+
A single regulated surface can also name a field per policy instead of by default:
|
|
724
758
|
|
|
725
759
|
```ruby
|
|
726
760
|
Clickwrap.policy :regulated_authorization do
|
|
@@ -228,10 +228,14 @@ module Clickwrap
|
|
|
228
228
|
@encrypt_recorded_ip_geolocation = true
|
|
229
229
|
|
|
230
230
|
# nil means "every policy that enables the field must supply its own
|
|
231
|
-
# rule"
|
|
231
|
+
# rule" — or, for by-default recording, that the host has said
|
|
232
|
+
# `keep_recorded_..._indefinitely!(because: "…")` out loud. Keeping
|
|
233
|
+
# forever is never silent: it is either the per-policy retention class's
|
|
234
|
+
# explicit business, or a named, reasoned sentence in the initializer.
|
|
232
235
|
@delete_recorded_ip_addresses_after = nil
|
|
233
236
|
@delete_recorded_browser_user_agents_after = nil
|
|
234
237
|
@delete_recorded_ip_geolocation_after = nil
|
|
238
|
+
@keep_recorded_request_evidence_indefinitely = {}
|
|
235
239
|
|
|
236
240
|
# Rails' request.remote_ip is the conventional reader. The host remains
|
|
237
241
|
# responsible for configuring and testing trusted proxies correctly:
|
|
@@ -895,13 +899,22 @@ module Clickwrap
|
|
|
895
899
|
"application's reviewed, present-tense reason, or turn that default off."
|
|
896
900
|
end
|
|
897
901
|
|
|
898
|
-
|
|
902
|
+
if delete_after.present? && keeps_recorded_request_evidence_indefinitely?(category)
|
|
903
|
+
raise ConfigurationError,
|
|
904
|
+
"Clickwrap is told both to delete recorded #{category} after " \
|
|
905
|
+
"#{delete_after.inspect} and to keep it indefinitely. Those are opposite " \
|
|
906
|
+
"decisions — keep exactly one."
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
next if delete_after.present? || keeps_recorded_request_evidence_indefinitely?(category)
|
|
899
910
|
|
|
900
911
|
raise ConfigurationError,
|
|
901
|
-
"Clickwrap is set to record #{category} for every policy by default, but " \
|
|
902
|
-
"
|
|
903
|
-
"
|
|
904
|
-
"
|
|
912
|
+
"Clickwrap is set to record #{category} for every policy by default, but nothing " \
|
|
913
|
+
"says how long to keep it. Either set a reviewed period with " \
|
|
914
|
+
"`delete_recorded_#{plural_for(category)}_after`, or keep it as long as the " \
|
|
915
|
+
"evidence it corroborates with " \
|
|
916
|
+
"`keep_recorded_#{plural_for(category)}_indefinitely!(because: \"…\")` — or turn " \
|
|
917
|
+
"the default off and let each policy choose its own retention rule."
|
|
905
918
|
end
|
|
906
919
|
end
|
|
907
920
|
|
|
@@ -913,6 +926,16 @@ module Clickwrap
|
|
|
913
926
|
end
|
|
914
927
|
end
|
|
915
928
|
|
|
929
|
+
def declare_indefinite_request_evidence!(category, because)
|
|
930
|
+
if because.to_s.strip.empty?
|
|
931
|
+
raise ConfigurationError,
|
|
932
|
+
"keep_recorded_#{plural_for(category)}_indefinitely! needs a `because:` " \
|
|
933
|
+
"explaining the reviewed decision."
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
@keep_recorded_request_evidence_indefinitely[category] = because
|
|
937
|
+
end
|
|
938
|
+
|
|
916
939
|
def validate_trusted_proxy_configuration!
|
|
917
940
|
records_ip_derived_evidence =
|
|
918
941
|
record_ip_address_by_default || enabled_default_ip_geolocation_fields.any?
|
|
@@ -1098,6 +1121,32 @@ module Clickwrap
|
|
|
1098
1121
|
# The deliberate, named escape hatch referenced by `ensure_encryption_choice`.
|
|
1099
1122
|
# It exists so that turning encryption off is a sentence a reviewer can find
|
|
1100
1123
|
# in a diff, with the host's own reason attached, rather than a `false`.
|
|
1124
|
+
# The named escape hatch for by-default request evidence with no deletion
|
|
1125
|
+
# clock: request evidence exists to corroborate evidence that (since 0.2.0)
|
|
1126
|
+
# keeps indefinitely by default, and a corroboration that expires before
|
|
1127
|
+
# the thing it corroborates is a scheduled weakening of the record. Same
|
|
1128
|
+
# rule as every escape hatch here: keeping forever must be a sentence a
|
|
1129
|
+
# reviewer can find in a diff, with the host's own reason attached.
|
|
1130
|
+
def keep_recorded_ip_addresses_indefinitely!(because:)
|
|
1131
|
+
declare_indefinite_request_evidence!(:ip_address, because)
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
def keep_recorded_browser_user_agents_indefinitely!(because:)
|
|
1135
|
+
declare_indefinite_request_evidence!(:browser_user_agent, because)
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
def keep_recorded_ip_geolocation_indefinitely!(because:)
|
|
1139
|
+
declare_indefinite_request_evidence!(:ip_geolocation, because)
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
def keeps_recorded_request_evidence_indefinitely?(category)
|
|
1143
|
+
@keep_recorded_request_evidence_indefinitely.key?(category.to_sym)
|
|
1144
|
+
end
|
|
1145
|
+
|
|
1146
|
+
def reason_for_keeping_recorded_request_evidence_indefinitely(category)
|
|
1147
|
+
@keep_recorded_request_evidence_indefinitely[category.to_sym]
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1101
1150
|
def deliberately_store_request_evidence_unencrypted!(because:)
|
|
1102
1151
|
if because.to_s.strip.empty?
|
|
1103
1152
|
raise ConfigurationError,
|
|
@@ -69,6 +69,21 @@ module Clickwrap
|
|
|
69
69
|
assign_rule!(:ip_geolocation, host_event_name:)
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# Keeping the annex as long as the core event it corroborates, said in
|
|
73
|
+
# the retention class itself. A corroboration that expires before the
|
|
74
|
+
# evidence it corroborates is a scheduled weakening of the record.
|
|
75
|
+
def keep_recorded_ip_address_indefinitely
|
|
76
|
+
assign_rule!(:ip_address, indefinite: true)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def keep_recorded_browser_user_agent_indefinitely
|
|
80
|
+
assign_rule!(:browser_user_agent, indefinite: true)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def keep_recorded_ip_geolocation_indefinitely
|
|
84
|
+
assign_rule!(:ip_geolocation, indefinite: true)
|
|
85
|
+
end
|
|
86
|
+
|
|
72
87
|
def compile = RetentionClass.new(key: @key, rules: @rules)
|
|
73
88
|
|
|
74
89
|
private
|
|
@@ -483,10 +483,14 @@ module Clickwrap
|
|
|
483
483
|
|
|
484
484
|
# --- Retention ------------------------------------------------------------
|
|
485
485
|
|
|
486
|
-
# Every recorded field leaves here with a disposal
|
|
487
|
-
# of a host rule that will produce one
|
|
488
|
-
#
|
|
489
|
-
#
|
|
486
|
+
# Every recorded field leaves here with a disposal answer: a date, the name
|
|
487
|
+
# of a host rule that will produce one, or the explicit reviewed decision
|
|
488
|
+
# to keep it as long as the evidence it corroborates. Keeping forever is
|
|
489
|
+
# never a silent default — it is a named declaration in the retention class
|
|
490
|
+
# (`keep_recorded_..._indefinitely`) or the initializer
|
|
491
|
+
# (`keep_recorded_..._indefinitely!(because: "…")`) — and a recorded field
|
|
492
|
+
# with no answer at all is a configuration bug caught before the row is
|
|
493
|
+
# written rather than a row nobody ever decided about.
|
|
490
494
|
#
|
|
491
495
|
# `retain_until` names a host calculation instead of a duration because real
|
|
492
496
|
# record-keeping schedules are not always durations — "five years, or three
|
|
@@ -500,6 +504,14 @@ module Clickwrap
|
|
|
500
504
|
return { "#{category}_delete_after": now + class_rule.duration } if class_rule&.duration?
|
|
501
505
|
return { "#{category}_retain_until_rule": class_rule.host_event_name.to_s } if class_rule&.host_event?
|
|
502
506
|
|
|
507
|
+
# Indefinite — declared in the class or application-wide — stamps
|
|
508
|
+
# nothing: the blank schedule plus the recorded declaration IS the
|
|
509
|
+
# disposal answer, exactly like an indefinite core event.
|
|
510
|
+
if class_rule&.indefinite? ||
|
|
511
|
+
Clickwrap.config.keeps_recorded_request_evidence_indefinitely?(category)
|
|
512
|
+
return {}
|
|
513
|
+
end
|
|
514
|
+
|
|
503
515
|
raise ConfigurationError, missing_retention_message(category)
|
|
504
516
|
end
|
|
505
517
|
|
|
@@ -510,11 +522,13 @@ module Clickwrap
|
|
|
510
522
|
end
|
|
511
523
|
|
|
512
524
|
def missing_retention_message(category)
|
|
513
|
-
"Clickwrap is about to record #{category} for policy #{policy_key} and nothing says
|
|
514
|
-
"to
|
|
515
|
-
"`retain_until:` naming a host retention calculation —
|
|
516
|
-
"
|
|
517
|
-
"
|
|
525
|
+
"Clickwrap is about to record #{category} for policy #{policy_key} and nothing says what " \
|
|
526
|
+
"should ever happen to it. Give the policy a rule — `delete_after:` with a reviewed " \
|
|
527
|
+
"period, or `retain_until:` naming a host retention calculation — add a #{category} " \
|
|
528
|
+
"rule (or `keep_recorded_#{category}_indefinitely`) to retention class " \
|
|
529
|
+
"#{policy.retention_class_key.inspect}, or answer it application-wide with " \
|
|
530
|
+
"`keep_recorded_..._indefinitely!(because: \"…\")`. Keeping forever is never silent, " \
|
|
531
|
+
"and Clickwrap will not choose for you."
|
|
518
532
|
end
|
|
519
533
|
|
|
520
534
|
# --- Failing closed -------------------------------------------------------
|
|
@@ -205,12 +205,15 @@ module Clickwrap
|
|
|
205
205
|
"as a data-collection purpose."
|
|
206
206
|
end
|
|
207
207
|
|
|
208
|
-
if setting.delete_after.nil? && setting.retain_until.nil? && retention_class_key.nil?
|
|
208
|
+
if setting.delete_after.nil? && setting.retain_until.nil? && retention_class_key.nil? &&
|
|
209
|
+
!Clickwrap.config.keeps_recorded_request_evidence_indefinitely?(category)
|
|
209
210
|
raise DefinitionError,
|
|
210
|
-
"Policy #{policy_key} records #{category} but
|
|
211
|
-
"Give it `delete_after:` with a duration, or `retain_until:`
|
|
212
|
-
"rule,
|
|
213
|
-
"
|
|
211
|
+
"Policy #{policy_key} records #{category} but nothing says what should ever " \
|
|
212
|
+
"happen to it. Give it `delete_after:` with a duration, or `retain_until:` " \
|
|
213
|
+
"naming a host event rule, attach a retention class with a rule for this " \
|
|
214
|
+
"category, or answer it application-wide with " \
|
|
215
|
+
"`keep_recorded_..._indefinitely!(because: \"…\")`. Keeping forever is never " \
|
|
216
|
+
"silent, and Clickwrap will not choose for you."
|
|
214
217
|
end
|
|
215
218
|
|
|
216
219
|
return unless setting.delete_after && setting.delete_after.to_i <= 0
|
|
@@ -110,15 +110,32 @@ module Clickwrap
|
|
|
110
110
|
setting = policy.request_evidence.setting_for(category)
|
|
111
111
|
next unless setting.record?
|
|
112
112
|
next if setting.delete_after || setting.retain_until || retention_class.rule_for(category)
|
|
113
|
+
# The application-wide answer counts too: recording enabled in the
|
|
114
|
+
# initializer carries its disposal decision in the same place —
|
|
115
|
+
# either a global clock or the explicit, reasoned keep-indefinitely.
|
|
116
|
+
next if config_answers_disposal_for?(category)
|
|
113
117
|
|
|
114
118
|
raise DefinitionError,
|
|
115
|
-
"Policy #{policy.key} records #{category}, but
|
|
116
|
-
"
|
|
117
|
-
"
|
|
118
|
-
"
|
|
119
|
+
"Policy #{policy.key} records #{category}, but nothing says when to dispose of " \
|
|
120
|
+
"it. Add `delete_after:`/`retain_until:` to the policy, a plain-English " \
|
|
121
|
+
"request-evidence rule (or `keep_recorded_#{category}_indefinitely`) to " \
|
|
122
|
+
"retention class #{retention_class.key}, or answer it application-wide in the " \
|
|
123
|
+
"initializer with `delete_recorded_..._after` or " \
|
|
124
|
+
"`keep_recorded_..._indefinitely!(because: \"…\")`."
|
|
119
125
|
end
|
|
120
126
|
end
|
|
121
127
|
|
|
128
|
+
def config_answers_disposal_for?(category)
|
|
129
|
+
clock =
|
|
130
|
+
case category.to_sym
|
|
131
|
+
when :ip_address then Clickwrap.config.delete_recorded_ip_addresses_after
|
|
132
|
+
when :browser_user_agent then Clickwrap.config.delete_recorded_browser_user_agents_after
|
|
133
|
+
else Clickwrap.config.delete_recorded_ip_geolocation_after
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
clock.present? || Clickwrap.config.keeps_recorded_request_evidence_indefinitely?(category)
|
|
137
|
+
end
|
|
138
|
+
|
|
122
139
|
def validate_host_calculations!(policy, retention_class)
|
|
123
140
|
referenced = retention_class.rules.values.filter_map do |rule|
|
|
124
141
|
rule.host_event_name&.to_sym
|
data/lib/clickwrap/version.rb
CHANGED