clickwrap 0.3.0 → 0.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 504024fe0e6e146a907899092e9da9fe3f62501a2d2b251cb0f190cc72516860
4
- data.tar.gz: 542284305afe7e79d11e5759ce2c88630982ce7ad0862866a58a50824056b4d8
3
+ metadata.gz: 1ea29dba21d2740876c9708ad687520310951911eab737844b59577fbbe24464
4
+ data.tar.gz: 01140025f14f2c137ba35a25ab694898d31fdbc719a42a488d63204007b0dc85
5
5
  SHA512:
6
- metadata.gz: 34500cc2c493a726afa16ed51e592102109bf56750eeb19a67624b85d0565d98ec316f45ac8653ffe2aa6f757c690577d37b34b986c71e8f5cd95fa766648b6c
7
- data.tar.gz: 5ed6946bb7aa2319a3f27b11c40ff79a64d85759353567b1e46be342e7966e7f180eda5be79acf9229c156d5bbcf9397e2ad473184947dce2e64ec577bd615cb
6
+ metadata.gz: 752ed3dda0897564843222843c444a9c5c945493152ec04e697241871afda8df0eeea9635b3f07296a392f58c46abe763366daa759f0916cd5bb455f56a04e01
7
+ data.tar.gz: 71e5c6512a165734f941b7e5483b399e8d46e83a3963e05af5ae2d3e077dbcf4eac0b0e7eddebc9ff61b7a9d3b73c551502fb434388c65c1830e2b75a6e0add1
data/CHANGELOG.md CHANGED
@@ -6,6 +6,137 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.2] - 2026-08-20
10
+
11
+ ### Fixed — two guards that looked like they held
12
+
13
+ - **Canonical JSON refuses invalid bytes whatever their encoding tag.**
14
+ `valid_encoding?` is always true on an `ASCII-8BIT` string — BINARY has no
15
+ invalid byte sequences by definition — and BINARY is exactly what Rack and
16
+ CDN headers deliver, so the UTF-8 guard was a no-op for the ten
17
+ request-evidence values most likely to be malformed. Invalid bytes passed
18
+ through and produced canonical JSON that was not itself valid UTF-8, which
19
+ RFC 8785 forbids and a verifier in another language may reject or normalize
20
+ into a different digest. The guard now normalizes the tag before validating.
21
+ **No digest ever written changes**: bytes that are valid UTF-8 canonicalize
22
+ byte-identically whether they arrive tagged BINARY or UTF-8, which a test
23
+ pins. And a stored value that can no longer be canonicalized now reports a
24
+ binding mismatch instead of raising out of the integrity check — a check
25
+ that crashes tells an operator nothing except that the tool broke.
26
+ - **`record_ip_geolocation(country: nil)` is refused instead of quietly
27
+ enabling three fields.** With plain `nil` keyword defaults an explicit nil
28
+ was indistinguishable from an omitted keyword, so a policy written as
29
+ `record_ip_geolocation(country: settings[:geo])` with an empty setting fell
30
+ through to the coarse-trio default — enabling a category of personal data as
31
+ a side effect, which the frictionless pass never relaxed. A sentinel now
32
+ tells the two apart: unmentioned still gets the coarse trio, `false` still
33
+ reaches the coherence check that names `do_not_record_ip_geolocation`, and
34
+ `nil` raises a sentence.
35
+ - **A scaffolding `legal_basis_reference:` is refused like a scaffolding
36
+ `because:`.** The reference lands in the compiled policy revision and every
37
+ receipt built from it, permanently, where "TODO: ask legal" reads as a
38
+ reviewed determination rather than an omission. The option stays optional;
39
+ text the host actually wrote has to be text they meant.
40
+
41
+ ### Documentation
42
+
43
+ - The installer's purpose and retention prompts now describe what the
44
+ installer actually does since 0.3.x: a blank purpose is accepted (the gem
45
+ records its own stated purpose, marked as the gem's), a blank period keeps
46
+ pace with the evidence it corroborates, and only scaffolding text or a
47
+ negative period stops generation.
48
+
49
+ ## [0.3.1] - 2026-08-20
50
+
51
+ ### Changed — the rest of the collection friction, and the principle behind removing it
52
+
53
+ 0.3.0 made *enabling* request evidence one switch and gave the initializer
54
+ defaults honest gem-supplied purposes. It left the friction standing
55
+ everywhere else: a policy-level `record_ip_address` still read as though it
56
+ wanted a sentence, `record_ip_geolocation` refused to do anything without a
57
+ field list, and the encryption escape hatch still demanded a reason be
58
+ phrased twice. This finishes the job the owner asked for.
59
+
60
+ The principle, in their words: **the host application and its privacy policy
61
+ own *why* data is collected; this gem records *what* was collected, honestly,
62
+ and is nobody's nanny.** Its job is evidence mechanics, not gatekeeping
63
+ collection. Everything below now works exactly as written:
64
+
65
+ ```ruby
66
+ Clickwrap.configure do |config|
67
+ config.record_request_evidence_by_default = true
68
+ config.keep_recorded_ip_addresses_indefinitely!
69
+ config.deliberately_store_request_evidence_unencrypted!
70
+ end
71
+
72
+ Clickwrap.policy :anything do
73
+ agree_to :terms
74
+ record_ip_address
75
+ record_browser_user_agent
76
+ record_ip_geolocation
77
+ end
78
+ ```
79
+
80
+ - **All three `record_` verbs take zero keyword arguments.** `because:`,
81
+ `legal_basis_reference:`, `data_protection_impact_assessment_reference:`,
82
+ `delete_after:`, `retain_until:`, and `encrypted:` are optional in fact and
83
+ not merely in the signature — nothing downstream refuses their absence.
84
+ (`legal_basis_reference` and the DPIA reference never were required anywhere
85
+ in the gem; a test now pins that they never become so.)
86
+ - **`record_ip_geolocation` with no field named records the coarse trio** —
87
+ country, region, city — the same set `record_request_evidence_by_default`
88
+ turns on, and nothing finer. The field keywords moved from `false` to `nil`
89
+ defaults so "did not mention this field" is distinguishable from "named it
90
+ and turned it off". Naming even one field means you are choosing the set
91
+ yourself, and the set is exactly what you named. Naming every field `false`
92
+ is still refused, because calling the verb and disabling everything cannot
93
+ mean anything; `do_not_record_ip_geolocation` is how to say that.
94
+ - **`keep_recorded_{ip_addresses,browser_user_agents,ip_geolocation}_indefinitely!`
95
+ take no arguments at all.**
96
+ - **`deliberately_store_request_evidence_unencrypted!` no longer needs a
97
+ `because:`.** The method NAME is the ceremony: `encrypt_recorded_* = false`
98
+ still cannot be reached without writing that line, and a reviewer still
99
+ finds it in the diff. When the host writes no reason the gem records
100
+ `Vocabulary::DEFAULT_REASON_FOR_STORING_REQUEST_EVIDENCE_UNENCRYPTED`.
101
+ Encryption itself is untouched — on by default for all three categories,
102
+ with a test pinning that the one switch does not weaken it.
103
+ - **The install generator stops refusing an incomplete category.**
104
+ `--record-ip-addresses-by-default` with no reason and no period writes the
105
+ file and simply omits those two lines, so the gem's own defaults apply.
106
+ - The `ReviewedText` placeholder check now only ever applies to text a host
107
+ actually supplied. Absence is never scaffolding.
108
+
109
+ ### Unchanged, deliberately
110
+
111
+ - Reasons that are not about *collection* keep their required `because:`:
112
+ `delete_recorded_ip_address!` and its siblings, `dispose_core_event!`,
113
+ `place_on_legal_hold!` / `release_legal_hold!`, `plan_disposition_for`,
114
+ unredacted receipt export, and the lifecycle verbs. Those record a
115
+ destructive act, an access, or a state change — there the audit trail *is*
116
+ the reason, and there is no honest default for "why did somebody delete
117
+ this".
118
+ - Still refused, because each is the host contradicting themselves rather than
119
+ leaving a blank: scaffolding text the host actually wrote standing in for a
120
+ purpose (including in the installer, where a `TODO` in a shipped initializer
121
+ is worse than no line and the gem would reject it at boot anyway), a
122
+ negative deletion period passed to the installer, a deletion clock declared
123
+ alongside `keep_recorded_..._indefinitely!` for the same category, and
124
+ `record_ip_geolocation` with every field explicitly off.
125
+ - The gem's code default is still record-nothing. Claim boundaries, receipt
126
+ state labeling (`not_configured` / `unavailable` / `recorded` /
127
+ `deleted_after_retention`), and every released receipt format are untouched.
128
+
129
+ ### Documentation
130
+
131
+ - README, `guides/request-evidence.md`, and `guides/naming.md` show the
132
+ zero-keyword forms as the ordinary way to write a policy, with purposes,
133
+ legal bases, and clocks as the upgrade path.
134
+ - `CLAUDE.md` / `AGENTS.md` rule 6 is rewritten around the principle above and
135
+ records the owner directive and its date. What it keeps as non-negotiable:
136
+ never a switch whose NAME hides what it collects, never an overclaim, never
137
+ a blurred receipt state, encryption on by default behind its named call,
138
+ host-supplied scaffolding rejected, and contradictions refused.
139
+
9
140
  ## [0.3.0] - 2026-08-20
10
141
 
11
142
  ### Changed — recording request evidence is one switch, not a checklist
data/README.md CHANGED
@@ -733,7 +733,7 @@ end
733
733
 
734
734
  That records, on every policy: the IP address the request arrived from, the browser user agent it sent, and a coarse country / region / city estimate for that address. Add [`trackdown`](https://github.com/rameerez/trackdown) 0.4+ to your Gemfile and the geolocation half resolves itself — Clickwrap picks up the official adapter with no wiring line, so trackdown plus Cloudflare genuinely is "bundle it and flip the switch".
735
735
 
736
- Nothing else is required, because Clickwrap supplies honest defaults for the parts you did not write:
736
+ Nothing else is required — anywhere, at either level. **Your privacy policy owns the why; the gem records the what, honestly, and is nobody's nanny.** Clickwrap supplies its own defaults for every part you did not write:
737
737
 
738
738
  - **Purpose.** Every recorded field carries one into the receipt. Yours if you wrote one, otherwise Clickwrap's: *"Corroborate who performed each recorded act, from where, on what client — to defend the recorded agreement itself."* The [privacy inventory](#operations) marks which of the two it is reading back (`"purpose_source": "gem_default"` vs `"host"`), so a gem sentence never passes for a decision your team reviewed.
739
739
  - **How long.** No clock means it keeps pace with the evidence it corroborates — the same posture core evidence has had since 0.2.0. A corroboration scheduled to expire before the agreement it corroborates is a scheduled weakening of the record.
@@ -772,7 +772,23 @@ end
772
772
 
773
773
  Two things are still refused, and both are you contradicting yourself rather than leaving a blank: scaffolding text (`"TODO: ask legal"`) standing in for a purpose, and a deletion clock set alongside `keep_recorded_..._indefinitely!` for the same category.
774
774
 
775
- A single regulated surface can also name a field per policy instead of by default:
775
+ ### Per policy, with as much or as little as you want
776
+
777
+ A single surface can name the fields itself instead of inheriting the default. The frictionless form takes no arguments at all:
778
+
779
+ ```ruby
780
+ Clickwrap.policy :withdrawal_authorization do
781
+ authorize :regulated_action, one_time: true, valid_for: 10.minutes
782
+
783
+ record_ip_address
784
+ record_browser_user_agent
785
+ record_ip_geolocation
786
+ end
787
+ ```
788
+
789
+ `record_ip_geolocation` with no field named records the same coarse trio as the switch — country, region, city. Name even one field and you are choosing the set yourself, and the set is exactly what you named.
790
+
791
+ The same declarations with the full record a reviewed team would want:
776
792
 
777
793
  ```ruby
778
794
  Clickwrap.policy :regulated_authorization do
@@ -791,9 +807,11 @@ end
791
807
  Recorded values live in a separately encrypted annex with their own retention, so
792
808
  they can be deleted later without rewriting the core event payload. Core payloads
793
809
  have their own reviewed disposition path and leave a digest-linked tombstone.
794
- Encryption is on by default and turning it off keeps its own ceremony —
795
- `config.deliberately_store_request_evidence_unencrypted!(because: "…")` — because
796
- that one is a real hazard, not paperwork.
810
+ Encryption is on by default for all three categories. Turning it off keeps its
811
+ own ceremony — you cannot reach `encrypt_recorded_ip_addresses = false` without
812
+ first writing `config.deliberately_store_request_evidence_unencrypted!` but the
813
+ ceremony is the method name a reviewer finds in the diff, not a sentence the gem
814
+ makes you type. `because:` there is optional too.
797
815
 
798
816
  For IP geolocation, [`trackdown`](https://github.com/rameerez/trackdown) 0.4 or newer is the official resolver, and Clickwrap uses it automatically when your bundle has it and you named no resolver of your own. Set it explicitly when you want a different provider per policy, or when you are wiring Trackdown's per-request CDN trust:
799
817
 
data/guides/migrating.md CHANGED
@@ -333,3 +333,26 @@ never satisfy a human-action predicate.)
333
333
  | [FinePrint README at the audited commit](https://github.com/openstax/fine_print/blob/3b75fbcbcfb048ecd2f4ee7c4f0b9bd3d10f7603/README.md#L7-L25) | Pinned source code |
334
334
  | [FinePrint signature model at the audited commit](https://github.com/openstax/fine_print/blob/3b75fbcbcfb048ecd2f4ee7c4f0b9bd3d10f7603/app/models/fine_print/signature.rb#L1-L33) | Pinned source code |
335
335
  | The never-synthesize rule, the `unknown:` vocabulary, and the dry-run-first workflow | Product-design inference |
336
+
337
+ ## Completeness: enumerate writers, not rows
338
+
339
+ A migration inventory built by looking at what the database HOLDS will miss
340
+ what the code can WRITE. One production integration classified 52 distinct
341
+ audit event types from its data — the codebase carried ~150: everything rare
342
+ (incident and recovery paths that had never fired) and everything new
343
+ (writers added after the cutover) was invisible to the data-only sweep, and
344
+ one of the missed stores turned out to hold the product's most consequential
345
+ assent act.
346
+
347
+ Sweep three ways, and reconcile the lists:
348
+
349
+ 1. **Schema**: every table and column whose name says assent — accepted,
350
+ declared, consent, attest, authoriz, acknowledg, signed.
351
+ 2. **Writers**: every place code can create such a record — grep the literals
352
+ (`event_type:`, ledger inserts), not the rows.
353
+ 3. **Structure**: every model already carrying `has_clickwrap_evidence`, and
354
+ every dual-write site.
355
+
356
+ Then write a disposition for every entry — import it, "not a clickwrap" with
357
+ the reason, or "provider record" — in a tracked document. Silently ignoring a
358
+ store is not a valid disposition.
data/guides/naming.md CHANGED
@@ -291,7 +291,7 @@ object.
291
291
  | `chain_event_history_with`, `anchor_event_history_with`, `timestamp_receipts_with` | one `integrity_level` setting | Three different mechanisms making three different claims. One setting would let a reader infer the strongest from the presence of the weakest |
292
292
  | `after_event_is_committed` | `after_commit` | Says which commit, and reads as a sentence |
293
293
  | `authorize_unredacted_request_evidence_access_with` | `access_control` | Long, and correct. It names exactly which access it authorizes |
294
- | `deliberately_store_request_evidence_unencrypted!(because:)` | `encryption: false` | Turning encryption off should be a sentence a reviewer can find in a diff, with the host's own reason attached not a `false` |
294
+ | `deliberately_store_request_evidence_unencrypted!` | `encryption: false` | Turning encryption off should be a sentence a reviewer can find in a diff not a `false`. The method NAME is the ceremony; its `because:` is optional |
295
295
 
296
296
  The last row is the pattern worth copying. When an option has a consequence somebody should
297
297
  have to think about, make the name carry the thinking.
@@ -310,7 +310,26 @@ Clickwrap.policy :regulated_authorization do
310
310
  end
311
311
  ```
312
312
 
313
- Every keyword there is doing work:
313
+ Every keyword there is doing work, and **every one of them is optional**. The same three
314
+ declarations with nothing at all supplied are valid, and record the same fields:
315
+
316
+ ```ruby
317
+ Clickwrap.policy :frictionless_regulated_authorization do
318
+ authorize :regulated_action, one_time: true, valid_for: 10.minutes
319
+
320
+ record_ip_address
321
+ record_browser_user_agent
322
+ record_ip_geolocation
323
+ retain_with :regulated_evidence
324
+ end
325
+ ```
326
+
327
+ `record_ip_geolocation` with no field named records the coarse trio — country, region, city —
328
+ and nothing finer. Name even one field and the set is exactly what you named; name every field
329
+ `false` and Clickwrap refuses, because calling `record_ip_geolocation` and disabling everything
330
+ cannot mean anything (`do_not_record_ip_geolocation` is how to say that).
331
+
332
+ What each keyword adds when you do supply it:
314
333
 
315
334
  - **`because:`** is the present purpose, in a sentence someone outside engineering can read. It
316
335
  is stored and printed by `bin/rails clickwrap:privacy:inventory`. It is optional: a policy
@@ -319,7 +338,9 @@ Every keyword there is doing work:
319
338
  reviewed. What is refused is scaffolding text — `"TODO: ask legal"` is not a purpose.
320
339
  - **`legal_basis_reference:`** and **`data_protection_impact_assessment_reference:`** are
321
340
  host-supplied pointers to your own reviewed documents. Clickwrap stores them. It does not
322
- read them, validate them, or endorse them.
341
+ read them, validate them, endorse them, or ever require them — nothing in the gem refuses a
342
+ recorded field for want of either, and nothing ever will. Your privacy policy owns the why;
343
+ the gem records the what.
323
344
  - **`delete_after:`** and **`retain_until:`** are both optional. When neither the policy, its
324
345
  retention class, nor the configuration names a schedule, the field keeps pace with the
325
346
  evidence it corroborates — the same posture the core event has — and the annex is stamped
@@ -554,3 +575,27 @@ can never undo or stand in for the Clickwrap event. Clickwrap ships no Footprint
554
575
  | [Trackdown v0.4.0 result object](https://github.com/rameerez/trackdown/blob/v0.4.0/lib/trackdown/location_result.rb), [configuration](https://github.com/rameerez/trackdown/blob/v0.4.0/lib/trackdown/configuration.rb), [Cloudflare provider](https://github.com/rameerez/trackdown/blob/v0.4.0/lib/trackdown/providers/cloudflare_provider.rb), [MaxMind provider](https://github.com/rameerez/trackdown/blob/v0.4.0/lib/trackdown/providers/maxmind_provider.rb), [PR #9](https://github.com/rameerez/trackdown/pull/9) | Pinned released source and project change record |
555
576
  | [Footprinted pinned model](https://github.com/rameerez/footprinted/blob/03b714bd3fa31368a8ce6695433386128fb6f91c/lib/footprinted/footprint.rb#L7-L52), [tracking concern](https://github.com/rameerez/footprinted/blob/03b714bd3fa31368a8ce6695433386128fb6f91c/lib/footprinted/model.rb#L7-L65) | Pinned source code |
556
577
  | The field selection, the ordering of evidentiary priority, and every API prescription above | Product-design inference |
578
+
579
+ ## Rendering recorded values: encodings survive encryption
580
+
581
+ Rack hands header strings to Ruby tagged `ASCII-8BIT`, and encrypted
582
+ attributes preserve the original encoding through decryption — so a recorded
583
+ `browser_user_agent` read back from the annex is BINARY-tagged even when its
584
+ bytes are perfectly valid UTF-8. Concatenating it into a UTF-8 view buffer
585
+ raises `Encoding::CompatibilityError`, and because only live captures carry
586
+ an annex, the failure hides until the first real signup reaches an admin
587
+ screen (it took down a production operator panel exactly that way).
588
+
589
+ Normalize at the render boundary, not in storage — the stored bytes are the
590
+ evidence:
591
+
592
+ ```ruby
593
+ # clickwrap-doc-test: syntax-only — a host view helper, not gem configuration
594
+ def rendered_recorded_text(value)
595
+ utf8 = value.to_s.dup.force_encoding(Encoding::UTF_8)
596
+ utf8.valid_encoding? ? utf8 : utf8.scrub
597
+ end
598
+ ```
599
+
600
+ The gem's own screens never render raw annex values, so they are not exposed;
601
+ any host view that does — an operator panel, an export page — is.
@@ -132,10 +132,26 @@ module Clickwrap
132
132
  end
133
133
 
134
134
  def write_string(string, buffer)
135
- raise SerializationError, "Canonical JSON strings must be valid UTF-8" unless string.valid_encoding?
135
+ # `valid_encoding?` is ALWAYS true on an ASCII-8BIT string — BINARY has
136
+ # no invalid byte sequences by definition — and BINARY is exactly what
137
+ # Rack and CDN headers deliver, so this guard was a no-op for the
138
+ # strings most likely to be malformed. Ten request-evidence columns
139
+ # reach it.
140
+ #
141
+ # Normalizing the tag before validating is safe for every digest ever
142
+ # written: bytes that ARE valid UTF-8 canonicalize byte-identically
143
+ # whether they arrive tagged BINARY or UTF-8 (measured, and pinned by a
144
+ # test below). What changes is only the case that was broken —
145
+ # genuinely invalid bytes used to emit canonical JSON that was itself
146
+ # not valid UTF-8, which RFC 8785 forbids and a verifier in another
147
+ # language may reject or normalize into a different digest. That is the
148
+ # "still verifiable years later" promise failing silently, so it is
149
+ # refused at write time instead.
150
+ utf8 = string.encoding == Encoding::UTF_8 ? string : string.dup.force_encoding(Encoding::UTF_8)
151
+ raise SerializationError, "Canonical JSON strings must be valid UTF-8" unless utf8.valid_encoding?
136
152
 
137
153
  buffer << '"'
138
- string.each_char do |char|
154
+ utf8.each_char do |char|
139
155
  escape = ESCAPES[char]
140
156
  buffer << if escape
141
157
  escape
@@ -1227,15 +1227,19 @@ module Clickwrap
1227
1227
  @keep_recorded_request_evidence_indefinitely[category.to_sym]
1228
1228
  end
1229
1229
 
1230
- def deliberately_store_request_evidence_unencrypted!(because:)
1231
- if because.to_s.strip.empty?
1232
- raise ConfigurationError,
1233
- "deliberately_store_request_evidence_unencrypted! needs a `because:` explaining " \
1234
- "the reviewed decision."
1235
- end
1236
-
1230
+ # The named escape hatch for turning encryption off. The ceremony is the
1231
+ # method: you cannot reach `encrypt_recorded_* = false` without writing a
1232
+ # line that says out loud what you are doing, and that line is what a
1233
+ # reviewer finds in a diff. Since 0.3.1 the `because:` is optional — the
1234
+ # gem records its own sentence when you do not write one — because the
1235
+ # host's privacy policy owns the why, and demanding it twice never stopped
1236
+ # anybody who had already typed this method name.
1237
+ #
1238
+ # Encryption itself is unchanged: on by default, for all three categories.
1239
+ def deliberately_store_request_evidence_unencrypted!(because: nil)
1237
1240
  @deliberately_storing_request_evidence_unencrypted = true
1238
- @reason_for_storing_request_evidence_unencrypted = because
1241
+ @reason_for_storing_request_evidence_unencrypted =
1242
+ because.presence || Vocabulary::DEFAULT_REASON_FOR_STORING_REQUEST_EVIDENCE_UNENCRYPTED
1239
1243
  end
1240
1244
 
1241
1245
  def storing_request_evidence_unencrypted? = @deliberately_storing_request_evidence_unencrypted == true
@@ -239,24 +239,46 @@ module Clickwrap
239
239
  @request_evidence[:browser_user_agent] = RequestEvidencePolicy::NOT_RECORDED
240
240
  end
241
241
 
242
- # Each IP-geolocation data field is named separately, because each one is
243
- # a separate decision about what to keep about a person's network
242
+ # Each IP-geolocation data field can be named separately, because each one
243
+ # is a separate decision about what to keep about a person's network
244
244
  # context. `latitude_and_longitude` is one coupled choice: half a
245
245
  # coordinate is not a result. Whatever is enabled, the provider name,
246
246
  # source, estimated status, resolution time, and any accuracy or database
247
247
  # provenance the resolver supplies are stored with it automatically — a
248
248
  # policy cannot keep the coordinates and drop the uncertainty needed to
249
249
  # read them.
250
- def record_ip_geolocation(country: false, region: false, city: false, postal_code: false,
251
- latitude_and_longitude: false, timezone: false, continent: false,
252
- metro_code: false, accuracy_radius_in_kilometers: false,
250
+ #
251
+ # Naming no field at all is the frictionless form:
252
+ #
253
+ # record_ip_geolocation
254
+ #
255
+ # It records the same coarse trio as
256
+ # `config.record_request_evidence_by_default` — country, region, city —
257
+ # and nothing finer, because that is what "IP geolocation" means in this
258
+ # gem when nobody narrows it. Naming even one field means you are
259
+ # choosing the set yourself, and then the set is exactly what you named.
260
+ # Distinguishes "the policy never mentioned this field" from "the policy
261
+ # passed nil for it". With plain `nil` defaults the two are identical, and
262
+ # `record_ip_geolocation(country: settings[:geo])` with an empty setting
263
+ # silently enabled the coarse trio — enabling a category of personal data
264
+ # as a side effect, which is the one thing the frictionless pass never
265
+ # relaxed.
266
+ UNMENTIONED_FIELD = Object.new.freeze
267
+ private_constant :UNMENTIONED_FIELD
268
+
269
+ def record_ip_geolocation(country: UNMENTIONED_FIELD, region: UNMENTIONED_FIELD,
270
+ city: UNMENTIONED_FIELD, postal_code: UNMENTIONED_FIELD,
271
+ latitude_and_longitude: UNMENTIONED_FIELD,
272
+ timezone: UNMENTIONED_FIELD, continent: UNMENTIONED_FIELD,
273
+ metro_code: UNMENTIONED_FIELD,
274
+ accuracy_radius_in_kilometers: UNMENTIONED_FIELD,
253
275
  using: nil, encrypted: nil, delete_after: nil, retain_until: nil,
254
276
  fail_if_unavailable: false, because: nil,
255
277
  legal_basis_reference: nil,
256
278
  data_protection_impact_assessment_reference: nil,
257
279
  **unknown_options)
258
280
  refuse_unknown_options!("record_ip_geolocation", unknown_options)
259
- @ip_geolocation_fields = {
281
+ named = {
260
282
  "country" => country,
261
283
  "region" => region,
262
284
  "city" => city,
@@ -267,6 +289,7 @@ module Clickwrap
267
289
  "metro_code" => metro_code,
268
290
  "accuracy_radius_in_kilometers" => accuracy_radius_in_kilometers
269
291
  }
292
+ @ip_geolocation_fields = default_ip_geolocation_fields_when_none_named(named)
270
293
  @ip_geolocation_resolver_name = using
271
294
 
272
295
  @request_evidence[:ip_geolocation] = RequestEvidencePolicy::Setting.new(
@@ -404,6 +427,34 @@ module Clickwrap
404
427
  end
405
428
  end
406
429
 
430
+ # An UNMENTIONED field means "the policy did not mention this"; `false`
431
+ # means "the policy named it and turned it off". A policy that mentions
432
+ # nothing gets the coarse trio; a policy that explicitly sets every field
433
+ # to false still reaches the coherence check that tells it to say
434
+ # `do_not_record_ip_geolocation` instead.
435
+ #
436
+ # An explicit `nil` is neither, and is refused rather than guessed: it is
437
+ # almost always a variable that came out empty, and treating it as "the
438
+ # policy said nothing" would turn a missing setting into three enabled
439
+ # fields of personal data.
440
+ def default_ip_geolocation_fields_when_none_named(named)
441
+ ambiguous = named.select { |_, value| value.nil? }.keys
442
+ unless ambiguous.empty?
443
+ raise DefinitionError,
444
+ "Policy #{@key} passes nil for #{ambiguous.join(", ")} in " \
445
+ "`record_ip_geolocation`. Say `true` or `false` for each field you name, " \
446
+ "or leave it out entirely — Clickwrap will not read an empty value as " \
447
+ "permission to record it, and it will not read it as silence either."
448
+ end
449
+
450
+ mentioned = named.reject { |_, value| value.equal?(UNMENTIONED_FIELD) }
451
+ return named.transform_values { |value| value == true } if mentioned.any?
452
+
453
+ Vocabulary::IP_GEOLOCATION_DATA_FIELDS.to_h do |field|
454
+ [field, Vocabulary::COARSE_IP_GEOLOCATION_DATA_FIELDS.include?(field)]
455
+ end
456
+ end
457
+
407
458
  def resolved_ip_geolocation_fields(setting)
408
459
  return {} unless setting.record?
409
460
  return @ip_geolocation_fields if @request_evidence.key?(:ip_geolocation)
@@ -302,11 +302,18 @@ module Clickwrap
302
302
  key = Clickwrap.config.request_evidence_binding_key_for(key_id)
303
303
  return false if key.nil?
304
304
 
305
- computed = Digest.keyed_digest(
306
- CanonicalJson.generate(binding_body_for(category)),
307
- key: key,
308
- algorithm: digest_algorithm
309
- )
305
+ # A stored value that can no longer be canonicalized cannot reproduce its
306
+ # binding, and saying so is the honest answer — an integrity check that
307
+ # raises tells an operator nothing except that the tool broke.
308
+ begin
309
+ computed = Digest.keyed_digest(
310
+ CanonicalJson.generate(binding_body_for(category)),
311
+ key: key,
312
+ algorithm: digest_algorithm
313
+ )
314
+ rescue CanonicalJson::SerializationError
315
+ return false
316
+ end
310
317
  Digest.secure_compare?(computed, digest)
311
318
  end
312
319
 
@@ -229,6 +229,20 @@ module Clickwrap
229
229
  "purpose."
230
230
  end
231
231
 
232
+ # The legal-basis reference goes into the compiled policy revision and
233
+ # every receipt built from it, permanently. Scaffolding there is worse
234
+ # than an omission: an omission reads as "the host said nothing", while
235
+ # "TODO: ask legal" reads as a reviewed determination to anyone who finds
236
+ # it later. Same rule as `because:` — the option is optional, but text
237
+ # the host actually wrote has to be text they meant.
238
+ if ReviewedText.placeholder?(setting.legal_basis_reference)
239
+ raise DefinitionError,
240
+ "Policy #{policy_key} records #{category} with a `legal_basis_reference:` " \
241
+ "that is still scaffolding text (#{setting.legal_basis_reference.inspect}). " \
242
+ "Replace it with the application's own reference, or drop the option — " \
243
+ "Clickwrap would rather record nothing than record a TODO as a legal basis."
244
+ end
245
+
232
246
  return unless setting.delete_after && setting.delete_after.to_i <= 0
233
247
 
234
248
  raise DefinitionError,
@@ -241,8 +255,11 @@ module Clickwrap
241
255
 
242
256
  if ip_geolocation.record? && enabled.empty?
243
257
  raise DefinitionError,
244
- "Policy #{policy_key} calls `record_ip_geolocation` but enables no field. " \
245
- "Name the fields you actually need, for example `country: true`."
258
+ "Policy #{policy_key} calls `record_ip_geolocation` and then turns every field " \
259
+ "off, which cannot mean anything. Name the fields you want, for example " \
260
+ "`country: true`; call `record_ip_geolocation` with no fields at all for the " \
261
+ "coarse country, region, and city; or say `do_not_record_ip_geolocation` if that " \
262
+ "is what you meant."
246
263
  end
247
264
 
248
265
  if ip_geolocation.record? &&
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clickwrap
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.2"
5
5
 
6
6
  # The canonical schema version for receipts, event digests, and presentation
7
7
  # manifests. This is deliberately independent of VERSION: gem releases may
@@ -187,6 +187,15 @@ module Clickwrap
187
187
  DEFAULT_REASON_FOR_KEEPING_REQUEST_EVIDENCE_INDEFINITELY =
188
188
  "Corroboration lives as long as the evidence it corroborates"
189
189
 
190
+ # The reason recorded when a host turns off encryption for request evidence
191
+ # without writing their own. Calling the method is still the ceremony —
192
+ # `deliberately_store_request_evidence_unencrypted!` is a sentence a
193
+ # reviewer finds in a diff and cannot misread — but the gem no longer
194
+ # demands the sentence be phrased twice.
195
+ DEFAULT_REASON_FOR_STORING_REQUEST_EVIDENCE_UNENCRYPTED =
196
+ "The application deliberately stores request evidence unencrypted; the reason lives " \
197
+ "outside Clickwrap"
198
+
190
199
  # Provenance that travels with any stored IP-geolocation result. A policy
191
200
  # cannot keep provider-derived coordinates while stripping the uncertainty
192
201
  # needed to interpret them.
@@ -980,15 +980,18 @@ module Clickwrap
980
980
 
981
981
  def ask_purpose(label)
982
982
  say "\n Why does the application need #{label}? One plain sentence, in your own"
983
- say " words — it goes into the initializer and the privacy inventory. A blank"
984
- say " or scaffolding answer stops generation before Clickwrap writes any files."
983
+ say " words — it goes into the initializer and the privacy inventory. Leave it"
984
+ say " blank and Clickwrap records its own stated purpose instead, marked as the"
985
+ say " gem's; a scaffolding answer (TODO, FIXME) stops generation before any"
986
+ say " files are written."
985
987
  ask(" Purpose:").to_s.strip
986
988
  end
987
989
 
988
990
  def ask_retention_days(label)
989
991
  say "\n After how many days should Clickwrap delete #{label}?"
990
- say " Clickwrap does not invent a period. Enter the positive number your application"
991
- say " has reviewed; a blank or zero answer stops generation before files are written."
992
+ say " Enter the number of days your application reviewed, or leave it blank and"
993
+ say " #{label} keeps pace with the evidence it corroborates kept until a"
994
+ say " reviewed disposition removes it. A negative number stops generation."
992
995
  ask(" Days:").to_s.strip.to_i
993
996
  end
994
997
 
@@ -1160,23 +1163,30 @@ module Clickwrap
1160
1163
  validate_ip_geolocation_resolver_class_name!
1161
1164
  end
1162
1165
 
1166
+ # A missing purpose and a missing deletion period are both fine, and the
1167
+ # generated file simply omits those lines: the gem records its own stated
1168
+ # purpose and keeps the field as long as the evidence it corroborates.
1169
+ # Scaffolding text is the one thing still refused, because a `TODO` the
1170
+ # installer writes into a shipped initializer is worse than no line at
1171
+ # all — the gem would reject it at boot anyway.
1163
1172
  def validate_enabled_category!(label, enabled:, because:, delete_after_days:,
1164
1173
  reason_option:, retention_option:)
1165
1174
  return unless enabled
1166
1175
 
1167
- unless Clickwrap::ReviewedText.present_and_reviewed?(because)
1176
+ if Clickwrap::ReviewedText.placeholder?(because)
1168
1177
  raise Thor::Error,
1169
- "Clickwrap cannot enable #{label} with a blank or scaffolding reason. " \
1170
- "Give the application's reviewed, present-tense reason with " \
1171
- "#{reason_option}=\"...\", or turn that category off. No files were written."
1178
+ "Clickwrap cannot enable #{label} with a scaffolding reason " \
1179
+ "(#{because.inspect}). Give the application's reviewed, present-tense reason " \
1180
+ "with #{reason_option}=\"...\", or omit it entirely and let Clickwrap record " \
1181
+ "its own stated purpose. No files were written."
1172
1182
  end
1173
1183
 
1174
- return if delete_after_days.positive?
1184
+ return unless delete_after_days.negative?
1175
1185
 
1176
1186
  raise Thor::Error,
1177
- "Clickwrap cannot enable #{label} without a positive deletion period. " \
1178
- "Set #{retention_option}=DAYS to the period your application reviewed, or " \
1179
- "turn that category off. No files were written."
1187
+ "#{retention_option} cannot be negative (got #{delete_after_days}). Give the " \
1188
+ "number of days your application reviewed, or omit it and #{label} will keep " \
1189
+ "pace with the evidence it corroborates. No files were written."
1180
1190
  end
1181
1191
 
1182
1192
  def validate_ip_geolocation_coordinates!
@@ -287,19 +287,25 @@ Clickwrap.configure do |config|
287
287
  # event — the historical agreement, declaration, or authorization stays intact
288
288
  # and verifiable without it.
289
289
 
290
- <%- if record_ip_addresses? -%>
290
+ <%- if record_ip_addresses? && reason_for_recording_ip_addresses.present? -%>
291
291
  config.reason_for_recording_ip_addresses_by_default =
292
292
  <%= reason_for_recording_ip_addresses.inspect %>
293
+ <%- end -%>
294
+ <%- if record_ip_addresses? && delete_recorded_ip_addresses_after_days.positive? -%>
293
295
  config.delete_recorded_ip_addresses_after = <%= delete_recorded_ip_addresses_after_days %>.days
294
296
  <%- end -%>
295
- <%- if record_browser_user_agents? -%>
297
+ <%- if record_browser_user_agents? && reason_for_recording_browser_user_agents.present? -%>
296
298
  config.reason_for_recording_browser_user_agents_by_default =
297
299
  <%= reason_for_recording_browser_user_agents.inspect %>
300
+ <%- end -%>
301
+ <%- if record_browser_user_agents? && delete_recorded_browser_user_agents_after_days.positive? -%>
298
302
  config.delete_recorded_browser_user_agents_after = <%= delete_recorded_browser_user_agents_after_days %>.days
299
303
  <%- end -%>
300
- <%- if any_ip_geolocation_field? -%>
304
+ <%- if any_ip_geolocation_field? && reason_for_recording_ip_geolocation.present? -%>
301
305
  config.reason_for_recording_ip_geolocation_by_default =
302
306
  <%= reason_for_recording_ip_geolocation.inspect %>
307
+ <%- end -%>
308
+ <%- if any_ip_geolocation_field? && delete_recorded_ip_geolocation_after_days.positive? -%>
303
309
  config.delete_recorded_ip_geolocation_after = <%= delete_recorded_ip_geolocation_after_days %>.days
304
310
  <%- end -%>
305
311
  <%- if records_any_request_evidence? -%>
@@ -332,8 +338,9 @@ Clickwrap.configure do |config|
332
338
  # Turning one of these off puts the raw value in plain text in your database,
333
339
  # and therefore in every ordinary backup and database dump. It is allowed,
334
340
  # because some applications have a reviewed reason, but it is never a quiet
335
- # one-character change: Clickwrap refuses `false` until you have said why in
336
- # `config.deliberately_store_request_evidence_unencrypted!(because: "…")`.
341
+ # one-character change: Clickwrap refuses `false` until you have written
342
+ # `config.deliberately_store_request_evidence_unencrypted!` above it. Writing
343
+ # that line IS the whole ceremony — its `because:` is optional.
337
344
  #
338
345
  # config.encrypt_recorded_ip_addresses = true
339
346
  # config.encrypt_recorded_browser_user_agents = true
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clickwrap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez