clickwrap 0.2.0 → 0.3.0

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.
@@ -21,14 +21,18 @@ module Clickwrap
21
21
  # - Every hook defaults to a no-op, so the gem works untouched and a host
22
22
  # wires hooks only as needed.
23
23
  # - Nothing here collects personal data by default. Every `record_*` flag
24
- # starts false, and turning one on without a purpose and a retention
25
- # decision is a configuration error, not a warning.
24
+ # starts false. Turning one on takes one line and nothing else; the
25
+ # purpose and the disposal answer have honest gem-supplied defaults, and
26
+ # a host who wants reviewed ones writes them.
26
27
  #
27
28
  # One setting deserves its own note: there is deliberately no
28
29
  # `gdpr_compliant_mode`, `maximum_evidence`, `full_evidence`, or
29
- # `legal_proof`. An option that silently enables a category of personal data
30
- # is exactly the thing this gem exists not to do, and no runtime flag can
31
- # make a legal determination on your behalf.
30
+ # `legal_proof`. Those names hide what they collect and pretend to make a
31
+ # legal determination, which is exactly the thing this gem exists not to do.
32
+ # `record_request_evidence_by_default` is the opposite kind of switch: it
33
+ # says out loud what it records (an IP address, a browser user agent, and a
34
+ # coarse country/region/city estimate), it enables nothing finer, and it
35
+ # claims nothing about the law.
32
36
  class Configuration
33
37
  DOCUMENT_STORES = %i[database active_storage resolver].freeze
34
38
  DIGEST_ALGORITHMS = %i[sha256 sha384 sha512].freeze
@@ -228,10 +232,14 @@ module Clickwrap
228
232
  @encrypt_recorded_ip_geolocation = true
229
233
 
230
234
  # nil means "every policy that enables the field must supply its own
231
- # rule". There is no keep-forever default anywhere in this gem.
235
+ # rule" or, for by-default recording, that the host has said
236
+ # `keep_recorded_..._indefinitely!(because: "…")` out loud. Keeping
237
+ # forever is never silent: it is either the per-policy retention class's
238
+ # explicit business, or a named, reasoned sentence in the initializer.
232
239
  @delete_recorded_ip_addresses_after = nil
233
240
  @delete_recorded_browser_user_agents_after = nil
234
241
  @delete_recorded_ip_geolocation_after = nil
242
+ @keep_recorded_request_evidence_indefinitely = {}
235
243
 
236
244
  # Rails' request.remote_ip is the conventional reader. The host remains
237
245
  # responsible for configuring and testing trusted proxies correctly:
@@ -242,6 +250,8 @@ module Clickwrap
242
250
 
243
251
  @ip_geolocation_resolver = nil
244
252
  @ip_geolocation_resolvers = {}
253
+ @automatically_adopted_ip_geolocation_resolver = nil
254
+ @considered_automatic_ip_geolocation_resolver = false
245
255
  @fail_capture_when_ip_geolocation_is_unavailable = false
246
256
 
247
257
  # The keyed annex digest carries a key ID so a host can rotate keys
@@ -608,6 +618,49 @@ module Clickwrap
608
618
 
609
619
  # --- Request-evidence setters --------------------------------------------
610
620
 
621
+ # The one switch.
622
+ #
623
+ # config.record_request_evidence_by_default = true
624
+ #
625
+ # Records, on every policy: the IP address the request arrived from, the
626
+ # browser user agent it sent, and a coarse country/region/city estimate for
627
+ # that address. Nothing finer — a postal code, coordinates, a timezone, a
628
+ # metro code — and nothing else at all. The purpose and the disposal answer
629
+ # have honest gem defaults (see `Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE`
630
+ # and the keep-indefinitely posture below), so this line is genuinely the
631
+ # whole first step.
632
+ #
633
+ # It is a fan-out setter, not a mode: it writes the individual
634
+ # `record_*_by_default` flags, which means it composes with them in reading
635
+ # order. Write the switch first and a narrower flag after it to carve one
636
+ # field back out —
637
+ #
638
+ # config.record_request_evidence_by_default = true
639
+ # config.record_browser_user_agent_by_default = false
640
+ #
641
+ # — and any policy can still override all of it with `record_ip_address`,
642
+ # `do_not_record_ip_address`, and their siblings. Setting it to false turns
643
+ # the same three fields off and leaves the finer geolocation fields alone,
644
+ # because it never turned those on.
645
+ def record_request_evidence_by_default=(value)
646
+ enabled = ensure_boolean(value, "record_request_evidence_by_default")
647
+
648
+ @record_ip_address_by_default = enabled
649
+ @record_browser_user_agent_by_default = enabled
650
+ Vocabulary::COARSE_IP_GEOLOCATION_DATA_FIELDS.each do |field|
651
+ instance_variable_set(:"@record_ip_geolocation_#{field}_by_default", enabled)
652
+ end
653
+ end
654
+
655
+ # Reads back what the switch describes rather than a remembered assignment:
656
+ # true when all three coarse fields are on, however they were turned on.
657
+ def record_request_evidence_by_default
658
+ record_ip_address_by_default && record_browser_user_agent_by_default &&
659
+ Vocabulary::COARSE_IP_GEOLOCATION_DATA_FIELDS.all? do |field|
660
+ public_send(:"record_ip_geolocation_#{field}_by_default")
661
+ end
662
+ end
663
+
611
664
  def record_ip_address_by_default=(value)
612
665
  @record_ip_address_by_default = ensure_boolean(value, "record_ip_address_by_default")
613
666
  end
@@ -711,11 +764,36 @@ module Clickwrap
711
764
  end
712
765
 
713
766
  def ip_geolocation_resolver_for(name = nil)
714
- return ip_geolocation_resolver if name.blank? || name.to_s == "application_default"
767
+ return application_default_ip_geolocation_resolver if name.blank? || name.to_s == "application_default"
715
768
 
716
769
  @ip_geolocation_resolvers[name.to_s]
717
770
  end
718
771
 
772
+ # What a policy gets when it does not name a resolver: the host's own, or —
773
+ # when they never set one and their bundle already carries `trackdown` 0.4
774
+ # or newer — the official adapter for it. That is the whole "trackdown plus
775
+ # Cloudflare just works" path, and it is deliberately not a silent
776
+ # collection decision: nothing calls this until a policy has already
777
+ # enabled an IP-geolocation field.
778
+ #
779
+ # An installed-but-too-old trackdown is NOT hidden here. The adapter's own
780
+ # sentence about upgrading is more useful than pretending the gem is
781
+ # missing.
782
+ def application_default_ip_geolocation_resolver
783
+ @ip_geolocation_resolver || automatically_adopted_ip_geolocation_resolver
784
+ end
785
+
786
+ # The resolver actually in force, for anything that only wants to describe
787
+ # the configuration (the privacy inventory, `clickwrap:doctor`). Unlike the
788
+ # reader above it never adopts one as a side effect of being asked.
789
+ def ip_geolocation_resolver_in_force
790
+ @ip_geolocation_resolver || @automatically_adopted_ip_geolocation_resolver
791
+ end
792
+
793
+ def ip_geolocation_resolver_was_adopted_automatically?
794
+ @ip_geolocation_resolver.nil? && !@automatically_adopted_ip_geolocation_resolver.nil?
795
+ end
796
+
719
797
  def ip_geolocation_resolver_names = @ip_geolocation_resolvers.keys.sort.freeze
720
798
 
721
799
  # Plain-English key-rotation API. The ID is evidence and must stay stable;
@@ -838,7 +916,6 @@ module Clickwrap
838
916
  # caught the typos; these are the things that need the whole block resolved.
839
917
  def validate!
840
918
  validate_request_evidence_defaults!
841
- validate_trusted_proxy_configuration!
842
919
  validate_ip_geolocation_resolver!
843
920
  true
844
921
  end
@@ -866,6 +943,15 @@ module Clickwrap
866
943
  Reference.record(actor)
867
944
  end
868
945
 
946
+ # Enabling a category by default needs nothing else. A purpose the host did
947
+ # not write falls back to Clickwrap's own stated one, and a disposal answer
948
+ # nobody gave means the corroboration keeps pace with the evidence it
949
+ # corroborates — which is what core evidence has done since 0.2.0.
950
+ #
951
+ # Two things still fail here, and both are the host contradicting
952
+ # themselves rather than merely leaving a blank: scaffolding text standing
953
+ # in for a purpose, and a deletion clock set alongside a declaration to
954
+ # keep the same category forever.
869
955
  def validate_request_evidence_defaults!
870
956
  {
871
957
  ip_address: [record_ip_address_by_default,
@@ -880,28 +966,20 @@ module Clickwrap
880
966
  }.each do |category, (enabled, reason, delete_after)|
881
967
  next unless enabled
882
968
 
883
- if reason.to_s.strip.empty?
884
- raise ConfigurationError,
885
- "Clickwrap is set to record #{category} for every policy by default, but " \
886
- "`reason_for_recording_#{plural_for(category)}_by_default` is blank. Say in one " \
887
- "plain sentence why the application needs it. If only some policies need it, " \
888
- "turn the default off and enable it in those policies instead."
889
- end
890
-
891
969
  if ReviewedText.placeholder?(reason)
892
970
  raise ConfigurationError,
893
971
  "Clickwrap is set to record #{category} for every policy by default, but its " \
894
972
  "reason is still scaffolding text (#{reason.inspect}). Replace it with the " \
895
- "application's reviewed, present-tense reason, or turn that default off."
973
+ "application's reviewed, present-tense reason, or delete the line and let " \
974
+ "Clickwrap record its own stated purpose."
896
975
  end
897
976
 
898
- next unless delete_after.nil?
977
+ next unless delete_after.present? && keeps_recorded_request_evidence_indefinitely?(category)
899
978
 
900
979
  raise ConfigurationError,
901
- "Clickwrap is set to record #{category} for every policy by default, but " \
902
- "`delete_recorded_#{plural_for(category)}_after` is nil, so nothing would ever " \
903
- "delete it. Set a reviewed period, or turn the default off and let each policy " \
904
- "choose its own retention rule."
980
+ "Clickwrap is told both to delete recorded #{category} after " \
981
+ "#{delete_after.inspect} and to keep it indefinitely. Those are opposite " \
982
+ "decisions keep exactly one."
905
983
  end
906
984
  end
907
985
 
@@ -913,31 +991,27 @@ module Clickwrap
913
991
  end
914
992
  end
915
993
 
916
- def validate_trusted_proxy_configuration!
917
- records_ip_derived_evidence =
918
- record_ip_address_by_default || enabled_default_ip_geolocation_fields.any?
919
- return unless records_ip_derived_evidence
920
- return if trusted_proxy_configuration_digest.present?
994
+ def host_reason_for_recording_by_default(category)
995
+ public_send(:"reason_for_recording_#{plural_for(category.to_sym)}_by_default").presence
996
+ end
921
997
 
922
- raise ConfigurationError,
923
- "Clickwrap is set to record an IP address or derive IP geolocation for every " \
924
- "policy, but `trusted_proxy_configuration_digest` is blank. Review and test the " \
925
- "deployment's trusted-proxy topology, digest that exact configuration, and set " \
926
- "the complete prefixed SHA-2 digest (for example `sha256:...`). This records which " \
927
- "proxy decision produced the address; it does not claim that decision was correct."
998
+ def declare_indefinite_request_evidence!(category, because)
999
+ @keep_recorded_request_evidence_indefinitely[category] =
1000
+ because.presence || Vocabulary::DEFAULT_REASON_FOR_KEEPING_REQUEST_EVIDENCE_INDEFINITELY
928
1001
  end
929
1002
 
930
1003
  def validate_ip_geolocation_resolver!
931
- return if ip_geolocation_resolver
932
1004
  return if enabled_default_ip_geolocation_fields.empty? && !fail_capture_when_ip_geolocation_is_unavailable
1005
+ return if application_default_ip_geolocation_resolver
933
1006
 
934
1007
  if enabled_default_ip_geolocation_fields.any?
935
1008
  raise ConfigurationError,
936
1009
  "Clickwrap is set to record the IP-geolocation fields " \
937
1010
  "#{enabled_default_ip_geolocation_fields.join(", ")} but no " \
938
- "`ip_geolocation_resolver` is configured, so there is nothing to resolve them. " \
939
- "Set one (for example Clickwrap::IpGeolocation::TrackdownResolver.new) or turn " \
940
- "the fields off."
1011
+ "`ip_geolocation_resolver` is configured and the `trackdown` gem is not " \
1012
+ "installed, so there is nothing to resolve them. Run " \
1013
+ "`bundle add trackdown --version \">= 0.4\"` and Clickwrap will use it, set " \
1014
+ "`config.ip_geolocation_resolver` to your own adapter, or turn the fields off."
941
1015
  end
942
1016
 
943
1017
  raise ConfigurationError,
@@ -945,6 +1019,22 @@ module Clickwrap
945
1019
  "`ip_geolocation_resolver` is configured, so every capture would fail."
946
1020
  end
947
1021
 
1022
+ # Considered once, at the first moment something actually needs geolocation
1023
+ # resolved, and remembered either way — including the "no trackdown here"
1024
+ # answer, so a host without it does not pay for a failed `require` on every
1025
+ # policy compile.
1026
+ def automatically_adopted_ip_geolocation_resolver
1027
+ return @automatically_adopted_ip_geolocation_resolver if @considered_automatic_ip_geolocation_resolver
1028
+
1029
+ # Remembered only once the adapter has actually been built. An installed
1030
+ # trackdown too old to use raises out of here, and a host who fixes their
1031
+ # bundle and asks again must not be told the gem is missing because a
1032
+ # failed attempt got memoized as "no".
1033
+ resolver = (IpGeolocation::TrackdownResolver.new if IpGeolocation::TrackdownResolver.installed?)
1034
+ @considered_automatic_ip_geolocation_resolver = true
1035
+ @automatically_adopted_ip_geolocation_resolver = resolver
1036
+ end
1037
+
948
1038
  # --- Setter helpers -------------------------------------------------------
949
1039
 
950
1040
  def ensure_callable(value, name)
@@ -1095,9 +1185,48 @@ module Clickwrap
1095
1185
 
1096
1186
  public
1097
1187
 
1098
- # The deliberate, named escape hatch referenced by `ensure_encryption_choice`.
1099
- # It exists so that turning encryption off is a sentence a reviewer can find
1100
- # in a diff, with the host's own reason attached, rather than a `false`.
1188
+ # Says out loud what an absent deletion clock already means: this category
1189
+ # keeps pace with the evidence it corroborates. Request evidence exists to
1190
+ # corroborate evidence that (since 0.2.0) keeps indefinitely by default,
1191
+ # and a corroboration that expires before the thing it corroborates is a
1192
+ # scheduled weakening of the record.
1193
+ #
1194
+ # Saying it explicitly is worth doing — it puts the decision and its reason
1195
+ # in the initializer where a reviewer finds them — but since 0.3.0 it is no
1196
+ # longer the price of admission, and `because:` is optional. What a host
1197
+ # writes is kept as their own words; what they leave out gets Clickwrap's.
1198
+ def keep_recorded_ip_addresses_indefinitely!(because: nil)
1199
+ declare_indefinite_request_evidence!(:ip_address, because)
1200
+ end
1201
+
1202
+ def keep_recorded_browser_user_agents_indefinitely!(because: nil)
1203
+ declare_indefinite_request_evidence!(:browser_user_agent, because)
1204
+ end
1205
+
1206
+ def keep_recorded_ip_geolocation_indefinitely!(because: nil)
1207
+ declare_indefinite_request_evidence!(:ip_geolocation, because)
1208
+ end
1209
+
1210
+ def keeps_recorded_request_evidence_indefinitely?(category)
1211
+ @keep_recorded_request_evidence_indefinitely.key?(category.to_sym)
1212
+ end
1213
+
1214
+ # The purpose that will actually be recorded for a category enabled
1215
+ # application-wide, and which of the two wrote it. The inventory reports
1216
+ # both, so a reviewer can tell a sentence their team signed off on from the
1217
+ # one the gem supplied.
1218
+ def reason_for_recording_by_default(category)
1219
+ host_reason_for_recording_by_default(category) || Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE
1220
+ end
1221
+
1222
+ def reason_for_recording_by_default_source(category)
1223
+ host_reason_for_recording_by_default(category) ? "host" : "gem_default"
1224
+ end
1225
+
1226
+ def reason_for_keeping_recorded_request_evidence_indefinitely(category)
1227
+ @keep_recorded_request_evidence_indefinitely[category.to_sym]
1228
+ end
1229
+
1101
1230
  def deliberately_store_request_evidence_unencrypted!(because:)
1102
1231
  if because.to_s.strip.empty?
1103
1232
  raise ConfigurationError,
@@ -271,7 +271,8 @@ module Clickwrap
271
271
  end
272
272
 
273
273
  def resolver_findings
274
- configured = Clickwrap.config.ip_geolocation_resolver
274
+ config = Clickwrap.config
275
+ configured = config.ip_geolocation_resolver_in_force
275
276
  wanted = Clickwrap.policies.values.select { |policy| policy.request_evidence.records_ip_geolocation? }
276
277
 
277
278
  if configured.nil?
@@ -285,7 +286,11 @@ module Clickwrap
285
286
  "as unavailable")]
286
287
  end
287
288
 
288
- [ok("an IP-geolocation resolver is configured (#{configured.class.name})")]
289
+ return [ok("an IP-geolocation resolver is configured (#{configured.class.name})")] unless
290
+ config.ip_geolocation_resolver_was_adopted_automatically?
291
+
292
+ [ok("IP geolocation resolves through #{configured.class.name}, which Clickwrap adopted " \
293
+ "because this application bundles trackdown and named no resolver of its own")]
289
294
  end
290
295
 
291
296
  # An IP address read from a forwarded header is only as good as the proxy
@@ -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
@@ -83,6 +83,22 @@ module Clickwrap
83
83
 
84
84
  attr_reader :capabilities
85
85
 
86
+ # Whether the host's bundle carries `trackdown` at all. The configuration
87
+ # asks this before adopting the adapter for a policy that enabled
88
+ # IP-geolocation fields without naming a resolver, so the common case —
89
+ # trackdown plus Cloudflare, already bundled — needs no wiring line.
90
+ #
91
+ # It answers the narrow question it is named after and nothing else. An
92
+ # installed release older than 0.4 answers `true` here and then fails in
93
+ # the constructor with the sentence about upgrading, which is far more
94
+ # useful to that host than being told the gem is missing.
95
+ def self.installed?
96
+ require "trackdown" unless defined?(::Trackdown)
97
+ true
98
+ rescue ::LoadError
99
+ false
100
+ end
101
+
86
102
  # Trust is per request in Trackdown 0.4. A host registers its verifier with
87
103
  # Trackdown, Trackdown runs it against the same request that supplied the
88
104
  # CDN headers, and this adapter copies the result's explicit trust state.
@@ -125,7 +125,7 @@ module Clickwrap
125
125
  "browser_user_agent" => default_category(config, :browser_user_agent),
126
126
  "ip_geolocation" => default_category(config, :ip_geolocation).merge(
127
127
  "fields" => config.enabled_default_ip_geolocation_fields,
128
- "resolver" => describe_resolver(config.ip_geolocation_resolver),
128
+ "resolver" => describe_resolver(config),
129
129
  "fail_capture_when_unavailable" => config.fail_capture_when_ip_geolocation_is_unavailable
130
130
  ),
131
131
  "review_default_request_evidence_configuration_on" =>
@@ -138,14 +138,25 @@ module Clickwrap
138
138
  }
139
139
  end
140
140
 
141
+ # `because` is the purpose that will actually be recorded, and
142
+ # `purpose_source` says who wrote it. A `"gem_default"` purpose is a real
143
+ # purpose and it is stored with the evidence — but nobody should mistake
144
+ # Clickwrap's own sentence for one the host's team reviewed, so the
145
+ # inventory never lets the two look alike.
141
146
  def default_category(config, category)
147
+ recorded = default_recorded?(config, category)
148
+
142
149
  {
143
- "recorded_by_default" => default_recorded?(config, category),
144
- "because" => config.public_send(:"reason_for_recording_#{plural_for(category)}_by_default"),
150
+ "recorded_by_default" => recorded,
151
+ "because" => recorded ? config.reason_for_recording_by_default(category) : nil,
152
+ "purpose_source" => (config.reason_for_recording_by_default_source(category) if recorded),
145
153
  "legal_basis_reference" =>
146
154
  config.public_send(:"legal_basis_reference_for_recording_#{plural_for(category)}_by_default"),
147
155
  "encrypted" => config.public_send(:"encrypt_recorded_#{plural_for(category)}"),
148
- "delete_after_seconds" => config.public_send(:"delete_recorded_#{plural_for(category)}_after")&.to_i
156
+ "delete_after_seconds" => config.public_send(:"delete_recorded_#{plural_for(category)}_after")&.to_i,
157
+ "kept_indefinitely" => config.keeps_recorded_request_evidence_indefinitely?(category),
158
+ "reason_for_keeping_indefinitely" =>
159
+ config.reason_for_keeping_recorded_request_evidence_indefinitely(category)
149
160
  }
150
161
  end
151
162
 
@@ -186,6 +197,7 @@ module Clickwrap
186
197
  entry = {
187
198
  "recorded" => setting.record?,
188
199
  "because" => setting.because,
200
+ "purpose_source" => policy.request_evidence.purpose_source_for(category),
189
201
  "legal_basis_reference" => setting.legal_basis_reference,
190
202
  "data_protection_impact_assessment_reference" =>
191
203
  setting.data_protection_impact_assessment_reference,
@@ -201,7 +213,7 @@ module Clickwrap
201
213
  entry.merge(
202
214
  "fields" => policy.request_evidence.enabled_ip_geolocation_fields,
203
215
  "resolver_named_by_policy" => policy.request_evidence.ip_geolocation_resolver_name&.to_s,
204
- "resolver" => describe_resolver(Clickwrap.config.ip_geolocation_resolver)
216
+ "resolver" => describe_resolver(Clickwrap.config)
205
217
  )
206
218
  end
207
219
 
@@ -283,10 +295,19 @@ module Clickwrap
283
295
  }
284
296
  end
285
297
 
286
- def describe_resolver(resolver)
298
+ # Reports the resolver in force, and whether the host named it or
299
+ # Clickwrap adopted the official trackdown adapter because their bundle
300
+ # already carried it. Asking never causes that adoption to happen — an
301
+ # inventory describes a configuration, it does not make one.
302
+ def describe_resolver(config)
303
+ resolver = config.ip_geolocation_resolver_in_force
287
304
  return { "configured" => false } if resolver.nil?
288
305
 
289
- { "configured" => true, "class" => resolver.class.name }
306
+ {
307
+ "configured" => true,
308
+ "class" => resolver.class.name,
309
+ "source" => config.ip_geolocation_resolver_was_adopted_automatically? ? "gem_default" : "host"
310
+ }
290
311
  end
291
312
 
292
313
  def plural_for(category)
@@ -244,7 +244,10 @@ module Clickwrap
244
244
 
245
245
  # Provenance is recorded even when the value is not, because "which reader
246
246
  # was asked, under which reviewed proxy configuration" is what tells a
247
- # later reader how much the address is worth.
247
+ # later reader how much the address is worth. A nil digest is part of
248
+ # that answer rather than a hole in it: it says no reviewed proxy
249
+ # configuration was recorded when this address was observed, which is
250
+ # exactly what a reader should know about it.
248
251
  provenance = {
249
252
  ip_address_reader_name: ip_address_reader_name,
250
253
  trusted_proxy_configuration_digest: policy.trusted_proxy_configuration_digest
@@ -483,10 +486,18 @@ module Clickwrap
483
486
 
484
487
  # --- Retention ------------------------------------------------------------
485
488
 
486
- # Every recorded field leaves here with a disposal rule: a date, or the name
487
- # of a host rule that will produce one. There is no keep-forever default
488
- # anywhere in this gem, and a recorded field with neither is a configuration
489
- # bug caught before the row is written rather than a row nobody ever deletes.
489
+ # Every recorded field leaves here with a disposal answer: a date, the name
490
+ # of a host rule that will produce one, or no schedule at all — which means
491
+ # it keeps pace with the evidence it corroborates, exactly like an
492
+ # indefinite core event. A blank schedule is the answer, not a gap: the
493
+ # planner never lists these rows, and the receipt reports them as recorded
494
+ # rather than as anything pending.
495
+ #
496
+ # Until 0.3.0 this raised when nothing had named a schedule, on the theory
497
+ # that keeping forever must never be silent. The theory held; the cost did
498
+ # not. It refused captures for hosts who had simply not written a sentence,
499
+ # and pushed integrators toward recording nothing — which is worse evidence
500
+ # than evidence kept under the same posture as the agreement it belongs to.
490
501
  #
491
502
  # `retain_until` names a host calculation instead of a duration because real
492
503
  # record-keeping schedules are not always durations — "five years, or three
@@ -500,7 +511,7 @@ module Clickwrap
500
511
  return { "#{category}_delete_after": now + class_rule.duration } if class_rule&.duration?
501
512
  return { "#{category}_retain_until_rule": class_rule.host_event_name.to_s } if class_rule&.host_event?
502
513
 
503
- raise ConfigurationError, missing_retention_message(category)
514
+ {}
504
515
  end
505
516
 
506
517
  def retention_class_rule_for(category)
@@ -509,14 +520,6 @@ module Clickwrap
509
520
  Clickwrap.retention_class!(policy.retention_class_key).rule_for(category)
510
521
  end
511
522
 
512
- def missing_retention_message(category)
513
- "Clickwrap is about to record #{category} for policy #{policy_key} and nothing says when " \
514
- "to delete it. Give the policy a rule — `delete_after:` with a reviewed period, or " \
515
- "`retain_until:` naming a host retention calculation — or add a #{category} rule to " \
516
- "retention class #{policy.retention_class_key.inspect}. Clickwrap has no keep-forever " \
517
- "default and will not choose a period for you."
518
- end
519
-
520
523
  # --- Failing closed -------------------------------------------------------
521
524
 
522
525
  # A policy can decide that evidence it cannot get is worse than no capture
@@ -8,9 +8,15 @@ module Clickwrap
8
8
  # not squeamishness about useful data: it is that high-quality evidence is
9
9
  # purpose-specific. An IP address is personal data, and keeping it on your own
10
10
  # infrastructure does not remove the duty to have a reason for it, protect it,
11
- # and stop keeping it eventually. So each field is enabled by name, with a
12
- # plain-English purpose and a retention decision attached, and the policy that
13
- # enables it is the server's, never the browser's.
11
+ # and stop keeping it eventually. So each field is enabled by name, and the
12
+ # policy that enables it is the server's, never the browser's.
13
+ #
14
+ # Naming a field is the whole requirement. Every recorded category still
15
+ # leaves here carrying a purpose and a disposal posture, because a snapshot
16
+ # read years from now has to answer both questions — but since 0.3.0 those
17
+ # answers have honest gem-supplied defaults instead of being the entry fee. A
18
+ # host who writes their own keeps their own words, and the privacy inventory
19
+ # reports which of the two is looking back at you.
14
20
  #
15
21
  # None of these fields is identity or physical location. An IP address is a
16
22
  # network observation. IP geolocation is a provider's estimate about that
@@ -61,9 +67,11 @@ module Clickwrap
61
67
  trusted_proxy_configuration_digest: nil, review_configuration_on: nil)
62
68
  @policy_key = policy_key
63
69
  @retention_class_key = retention_class_key&.to_s
70
+ @purpose_sources = {}
64
71
  @ip_address = normalized_setting(:ip_address, ip_address || NOT_RECORDED)
65
72
  @browser_user_agent = normalized_setting(:browser_user_agent, browser_user_agent || NOT_RECORDED)
66
73
  @ip_geolocation = normalized_setting(:ip_geolocation, ip_geolocation || NOT_RECORDED)
74
+ @purpose_sources.freeze
67
75
  @ip_geolocation_fields = normalize_geolocation_fields(ip_geolocation_fields)
68
76
  @ip_geolocation_resolver_name = ip_geolocation_resolver_name&.to_sym
69
77
  @trusted_proxy_configuration_digest = trusted_proxy_configuration_digest&.to_s
@@ -87,6 +95,15 @@ module Clickwrap
87
95
 
88
96
  def records_anything? = records_ip_address? || records_browser_user_agent? || records_ip_geolocation?
89
97
 
98
+ # Who wrote the purpose stored for this category: `"host"` when the policy
99
+ # or the initializer supplied one, `"gem_default"` when Clickwrap filled in
100
+ # its own. Deliberately kept off `to_snapshot`: the snapshot is a released
101
+ # evidence format, and the answer is derivable from the configuration a
102
+ # reader already has.
103
+ def purpose_source_for(category)
104
+ @purpose_sources[category.to_s]
105
+ end
106
+
90
107
  def setting_for(category)
91
108
  case category.to_sym
92
109
  when :ip_address then ip_address
@@ -132,7 +149,6 @@ module Clickwrap
132
149
  FIELD_CATEGORIES.each { |category| validate_category!(category) }
133
150
  validate_geolocation_coherence!
134
151
  validate_named_resolver!
135
- validate_trusted_proxy_configuration_digest!
136
152
  end
137
153
 
138
154
  def normalized_setting(category, setting)
@@ -153,7 +169,16 @@ module Clickwrap
153
169
  "`config.encrypt_recorded_*` setting, or omit `encrypted:` to inherit it."
154
170
  end
155
171
 
156
- Setting.new(**setting.to_h, encrypted: configured)
172
+ # The compiled revision always carries a purpose, so a reader years from
173
+ # now never finds a recorded field with nothing beside it saying what it
174
+ # was for. Whose sentence it is gets remembered separately.
175
+ @purpose_sources[category.to_s] = setting.because.presence ? "host" : "gem_default"
176
+
177
+ Setting.new(
178
+ **setting.to_h,
179
+ encrypted: configured,
180
+ because: setting.because.presence || Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE
181
+ )
157
182
  end
158
183
 
159
184
  def validate_named_resolver!
@@ -164,8 +189,9 @@ module Clickwrap
164
189
  raise DefinitionError,
165
190
  "Policy #{policy_key} records IP geolocation using resolver " \
166
191
  "#{ip_geolocation_resolver_name.inspect}, but no resolver is registered under " \
167
- "that name. Configure `config.ip_geolocation_resolver` for " \
168
- "`:application_default`, or register the named resolver with " \
192
+ "that name. Bundle `trackdown` (>= 0.4) and Clickwrap uses it for " \
193
+ "`:application_default` with no wiring line at all, or set " \
194
+ "`config.ip_geolocation_resolver` yourself, or register the named resolver with " \
169
195
  "`config.register_ip_geolocation_resolver`. Registered resolvers: " \
170
196
  "#{Clickwrap.config.ip_geolocation_resolver_names.join(", ").presence || "(none)"}."
171
197
  end
@@ -186,31 +212,21 @@ module Clickwrap
186
212
  "capabilities: #{error.message}"
187
213
  end
188
214
 
215
+ # A missing purpose and a missing disposal answer both have defaults now.
216
+ # What is left refuses only the two things a default cannot honestly stand
217
+ # in for: scaffolding text the host actually wrote, and a deletion clock
218
+ # that is not a period.
189
219
  def validate_category!(category)
190
220
  setting = setting_for(category)
191
221
  return unless setting.record?
192
222
 
193
- if setting.because.to_s.strip.empty?
194
- raise DefinitionError,
195
- "Policy #{policy_key} records #{category} but gives no `because:`. Say in one " \
196
- "plain sentence why this policy needs it right now. \"We might need it someday\" " \
197
- "is not a purpose, and a privacy notice mentioning the field is not one either."
198
- end
199
-
200
223
  if ReviewedText.placeholder?(setting.because)
201
224
  raise DefinitionError,
202
225
  "Policy #{policy_key} records #{category}, but its `because:` is still " \
203
226
  "scaffolding text (#{setting.because.inspect}). Replace it with the " \
204
- "application's reviewed, present-tense reason; Clickwrap never treats a TODO " \
205
- "as a data-collection purpose."
206
- end
207
-
208
- if setting.delete_after.nil? && setting.retain_until.nil? && retention_class_key.nil?
209
- raise DefinitionError,
210
- "Policy #{policy_key} records #{category} but never says when to delete it. " \
211
- "Give it `delete_after:` with a duration, or `retain_until:` naming a host event " \
212
- "rule, or attach a retention class with a rule for this category. Clickwrap has " \
213
- "no keep-forever default."
227
+ "application's reviewed, present-tense reason, or drop the option and let " \
228
+ "Clickwrap record its own stated purpose; a TODO is never a data-collection " \
229
+ "purpose."
214
230
  end
215
231
 
216
232
  return unless setting.delete_after && setting.delete_after.to_i <= 0
@@ -245,17 +261,5 @@ module Clickwrap
245
261
  "Policy #{policy_key} enables the IP-geolocation fields #{enabled.join(", ")} " \
246
262
  "without recording IP geolocation."
247
263
  end
248
-
249
- def validate_trusted_proxy_configuration_digest!
250
- return unless records_ip_address? || records_ip_geolocation?
251
- return unless trusted_proxy_configuration_digest.to_s.strip.empty?
252
-
253
- raise DefinitionError,
254
- "Policy #{policy_key} records an IP address or derives IP geolocation, but " \
255
- "`config.trusted_proxy_configuration_digest` is blank. Review the deployment's " \
256
- "trusted-proxy topology, digest that reviewed configuration, and set the prefixed " \
257
- "digest (for example `sha256:...`). This records which proxy decision produced the " \
258
- "address; it does not claim that the decision was correct."
259
- end
260
264
  end
261
265
  end