diff --git a/AGENTS.md b/AGENTS.md index 912ab18..b674154 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ Clickwrap's value is evidence that is still true and still verifiable years afte 3. **Released evidence formats are permanent.** Every released receipt schema, canonicalization profile, digest field, event action, and lifecycle meaning gets a golden fixture, and new versions must keep verifying old receipts. A format change means a new explicit schema and verifier, never a silent reinterpretation. Do not edit a released migration underneath an installed application; add an upgrade migration. 4. **Required writes cannot be error-isolated.** Evidence and the protected database action commit together or not at all. Optional after-commit hooks, analytics, and notifications are isolated and can never undo a committed action or stand in for one. 5. **The browser is not a policy author.** Policy key, revision, document versions, validity, subject binding, retention, and request-evidence fields are resolved server-side and rechecked at submit. Never add a hidden field, parameter, or header that lets a client choose any of them. -6. **Collecting nothing is the gem's default; collecting the ordinary trio is one honest switch.** Every `record_*` flag ships false. Turning on the coarse trio — IP address, browser user agent, and a country/region/city estimate — is `config.record_request_evidence_by_default = true` and nothing else: the purpose and the disposal answer have gem-supplied defaults (`Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE`, and no clock means it keeps pace with the evidence it corroborates), the per-field flags and per-policy `record_ip_address(...)`/`do_not_record_ip_address` still override it, and every finer geolocation field stays its own separately named one-liner. Never add a switch whose NAME hides what it collects (`gdpr_compliant_mode`, `full_evidence`, `maximum_evidence`, `legal_proof`) — that prohibition is about dishonest names and legal claims, and it survives; `record_request_evidence_by_default` says exactly what it records. **Owner directive, 2026-08-20 (v0.3.0), deliberately reversing the previous "every field needs a written purpose and a retention decision or boot fails" rule:** the ceremony was pushing integrators to collect nothing, and no evidence is worse than un-reviewed evidence. Do not "fix" this back. What is still refused: scaffolding text (`TODO`) standing in for a purpose the host actually wrote, a deletion clock set alongside a keep-indefinitely declaration for the same category, and turning encryption off without `deliberately_store_request_evidence_unencrypted!(because:)`. +6. **The host's privacy policy owns the why; the gem records the what, honestly.** Clickwrap is nobody's nanny. Its job is evidence mechanics, not gatekeeping collection: it never refuses to record something because a developer did not write a sentence justifying it. Every `record_*` flag still ships false, and the gem's own default is still record-nothing — but turning collection on is frictionless in every form. `config.record_request_evidence_by_default = true` records the coarse trio (IP address, browser user agent, country/region/city). `record_ip_address`, `record_browser_user_agent`, and `record_ip_geolocation` all take zero keyword arguments in a policy. Purposes, `legal_basis_reference:`, DPIA references, deletion clocks, `trusted_proxy_configuration_digest`, and the `because:` on `keep_recorded_*_indefinitely!` and `deliberately_store_request_evidence_unencrypted!` are ALL optional; gem-supplied honest defaults (`Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE` and friends) fill every purpose field so no record ever carries a blank, and the privacy inventory marks each one `gem_default` or `host` so the gem's sentence never passes for a reviewed one. What survives, and is not negotiable: never add a switch whose NAME hides what it collects (`gdpr_compliant_mode`, `full_evidence`, `maximum_evidence`, `legal_proof`); never overclaim (rule 1); never blur `not_configured` / `unavailable` / `recorded` / `deleted_after_retention`; encryption stays on by default and `= false` still requires the named `deliberately_store_request_evidence_unencrypted!` call; scaffolding text (`TODO`) a host actually supplied is still rejected rather than recorded as a purpose; and a deletion clock declared alongside a keep-indefinitely for the same category is still refused as a contradiction. **Owner directive, 2026-08-20 (v0.3.0), deliberately reversing the previous "every field needs a written purpose and a retention decision or boot fails" rule:** the ceremony was pushing integrators to collect nothing, and no evidence is worse than un-reviewed evidence. Do not "fix" this back. 7. **Names read aloud.** Complete verb-and-noun names, positive booleans, destructive methods that say exactly what they delete, `ip_address` not `ip`, `browser_user_agent` not `ua`, `ip_geolocation` not `location`, `http_request` not `context`, `recorded_at_by_server` not `signed_at`. If an example does not make sense read aloud by a developer who has never seen the gem, the name is wrong. ## Working here diff --git a/CHANGELOG.md b/CHANGELOG.md index 0674b12..bd0f3c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,97 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.1] - 2026-08-20 + +### Changed — the rest of the collection friction, and the principle behind removing it + +0.3.0 made *enabling* request evidence one switch and gave the initializer +defaults honest gem-supplied purposes. It left the friction standing +everywhere else: a policy-level `record_ip_address` still read as though it +wanted a sentence, `record_ip_geolocation` refused to do anything without a +field list, and the encryption escape hatch still demanded a reason be +phrased twice. This finishes the job the owner asked for. + +The principle, in their words: **the host application and its privacy policy +own *why* data is collected; this gem records *what* was collected, honestly, +and is nobody's nanny.** Its job is evidence mechanics, not gatekeeping +collection. Everything below now works exactly as written: + +```ruby +Clickwrap.configure do |config| + config.record_request_evidence_by_default = true + config.keep_recorded_ip_addresses_indefinitely! + config.deliberately_store_request_evidence_unencrypted! +end + +Clickwrap.policy :anything do + agree_to :terms + record_ip_address + record_browser_user_agent + record_ip_geolocation +end +``` + +- **All three `record_` verbs take zero keyword arguments.** `because:`, + `legal_basis_reference:`, `data_protection_impact_assessment_reference:`, + `delete_after:`, `retain_until:`, and `encrypted:` are optional in fact and + not merely in the signature — nothing downstream refuses their absence. + (`legal_basis_reference` and the DPIA reference never were required anywhere + in the gem; a test now pins that they never become so.) +- **`record_ip_geolocation` with no field named records the coarse trio** — + country, region, city — the same set `record_request_evidence_by_default` + turns on, and nothing finer. The field keywords moved from `false` to `nil` + defaults so "did not mention this field" is distinguishable from "named it + and turned it off". Naming even one field means you are choosing the set + yourself, and the set is exactly what you named. Naming every field `false` + is still refused, because calling the verb and disabling everything cannot + mean anything; `do_not_record_ip_geolocation` is how to say that. +- **`keep_recorded_{ip_addresses,browser_user_agents,ip_geolocation}_indefinitely!` + take no arguments at all.** +- **`deliberately_store_request_evidence_unencrypted!` no longer needs a + `because:`.** The method NAME is the ceremony: `encrypt_recorded_* = false` + still cannot be reached without writing that line, and a reviewer still + finds it in the diff. When the host writes no reason the gem records + `Vocabulary::DEFAULT_REASON_FOR_STORING_REQUEST_EVIDENCE_UNENCRYPTED`. + Encryption itself is untouched — on by default for all three categories, + with a test pinning that the one switch does not weaken it. +- **The install generator stops refusing an incomplete category.** + `--record-ip-addresses-by-default` with no reason and no period writes the + file and simply omits those two lines, so the gem's own defaults apply. +- The `ReviewedText` placeholder check now only ever applies to text a host + actually supplied. Absence is never scaffolding. + +### Unchanged, deliberately + +- Reasons that are not about *collection* keep their required `because:`: + `delete_recorded_ip_address!` and its siblings, `dispose_core_event!`, + `place_on_legal_hold!` / `release_legal_hold!`, `plan_disposition_for`, + unredacted receipt export, and the lifecycle verbs. Those record a + destructive act, an access, or a state change — there the audit trail *is* + the reason, and there is no honest default for "why did somebody delete + this". +- Still refused, because each is the host contradicting themselves rather than + leaving a blank: scaffolding text the host actually wrote standing in for a + purpose (including in the installer, where a `TODO` in a shipped initializer + is worse than no line and the gem would reject it at boot anyway), a + negative deletion period passed to the installer, a deletion clock declared + alongside `keep_recorded_..._indefinitely!` for the same category, and + `record_ip_geolocation` with every field explicitly off. +- The gem's code default is still record-nothing. Claim boundaries, receipt + state labeling (`not_configured` / `unavailable` / `recorded` / + `deleted_after_retention`), and every released receipt format are untouched. + +### Documentation + +- README, `guides/request-evidence.md`, and `guides/naming.md` show the + zero-keyword forms as the ordinary way to write a policy, with purposes, + legal bases, and clocks as the upgrade path. +- `CLAUDE.md` / `AGENTS.md` rule 6 is rewritten around the principle above and + records the owner directive and its date. What it keeps as non-negotiable: + never a switch whose NAME hides what it collects, never an overclaim, never + a blurred receipt state, encryption on by default behind its named call, + host-supplied scaffolding rejected, and contradictions refused. + ## [0.3.0] - 2026-08-20 ### Changed — recording request evidence is one switch, not a checklist diff --git a/CLAUDE.md b/CLAUDE.md index 702252a..4b75b0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ Clickwrap's value is evidence that is still true and still verifiable years afte 3. **Released evidence formats are permanent.** Every released receipt schema, canonicalization profile, digest field, event action, and lifecycle meaning gets a golden fixture, and new versions must keep verifying old receipts. A format change means a new explicit schema and verifier, never a silent reinterpretation. Do not edit a released migration underneath an installed application; add an upgrade migration. 4. **Required writes cannot be error-isolated.** Evidence and the protected database action commit together or not at all. Optional after-commit hooks, analytics, and notifications are isolated and can never undo a committed action or stand in for one. 5. **The browser is not a policy author.** Policy key, revision, document versions, validity, subject binding, retention, and request-evidence fields are resolved server-side and rechecked at submit. Never add a hidden field, parameter, or header that lets a client choose any of them. -6. **Collecting nothing is the gem's default; collecting the ordinary trio is one honest switch.** Every `record_*` flag ships false. Turning on the coarse trio — IP address, browser user agent, and a country/region/city estimate — is `config.record_request_evidence_by_default = true` and nothing else: the purpose and the disposal answer have gem-supplied defaults (`Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE`, and no clock means it keeps pace with the evidence it corroborates), the per-field flags and per-policy `record_ip_address(...)`/`do_not_record_ip_address` still override it, and every finer geolocation field stays its own separately named one-liner. Never add a switch whose NAME hides what it collects (`gdpr_compliant_mode`, `full_evidence`, `maximum_evidence`, `legal_proof`) — that prohibition is about dishonest names and legal claims, and it survives; `record_request_evidence_by_default` says exactly what it records. **Owner directive, 2026-08-20 (v0.3.0), deliberately reversing the previous "every field needs a written purpose and a retention decision or boot fails" rule:** the ceremony was pushing integrators to collect nothing, and no evidence is worse than un-reviewed evidence. Do not "fix" this back. What is still refused: scaffolding text (`TODO`) standing in for a purpose the host actually wrote, a deletion clock set alongside a keep-indefinitely declaration for the same category, and turning encryption off without `deliberately_store_request_evidence_unencrypted!(because:)`. +6. **The host's privacy policy owns the why; the gem records the what, honestly.** Clickwrap is nobody's nanny. Its job is evidence mechanics, not gatekeeping collection: it never refuses to record something because a developer did not write a sentence justifying it. Every `record_*` flag still ships false, and the gem's own default is still record-nothing — but turning collection on is frictionless in every form. `config.record_request_evidence_by_default = true` records the coarse trio (IP address, browser user agent, country/region/city). `record_ip_address`, `record_browser_user_agent`, and `record_ip_geolocation` all take zero keyword arguments in a policy. Purposes, `legal_basis_reference:`, DPIA references, deletion clocks, `trusted_proxy_configuration_digest`, and the `because:` on `keep_recorded_*_indefinitely!` and `deliberately_store_request_evidence_unencrypted!` are ALL optional; gem-supplied honest defaults (`Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE` and friends) fill every purpose field so no record ever carries a blank, and the privacy inventory marks each one `gem_default` or `host` so the gem's sentence never passes for a reviewed one. What survives, and is not negotiable: never add a switch whose NAME hides what it collects (`gdpr_compliant_mode`, `full_evidence`, `maximum_evidence`, `legal_proof`); never overclaim (rule 1); never blur `not_configured` / `unavailable` / `recorded` / `deleted_after_retention`; encryption stays on by default and `= false` still requires the named `deliberately_store_request_evidence_unencrypted!` call; scaffolding text (`TODO`) a host actually supplied is still rejected rather than recorded as a purpose; and a deletion clock declared alongside a keep-indefinitely for the same category is still refused as a contradiction. **Owner directive, 2026-08-20 (v0.3.0), deliberately reversing the previous "every field needs a written purpose and a retention decision or boot fails" rule:** the ceremony was pushing integrators to collect nothing, and no evidence is worse than un-reviewed evidence. Do not "fix" this back. 7. **Names read aloud.** Complete verb-and-noun names, positive booleans, destructive methods that say exactly what they delete, `ip_address` not `ip`, `browser_user_agent` not `ua`, `ip_geolocation` not `location`, `http_request` not `context`, `recorded_at_by_server` not `signed_at`. If an example does not make sense read aloud by a developer who has never seen the gem, the name is wrong. ## Working here diff --git a/README.md b/README.md index 7848488..4588db9 100644 --- a/README.md +++ b/README.md @@ -733,7 +733,7 @@ end 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". -Nothing else is required, because Clickwrap supplies honest defaults for the parts you did not write: +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: - **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. - **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 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. -A single regulated surface can also name a field per policy instead of by default: +### Per policy, with as much or as little as you want + +A single surface can name the fields itself instead of inheriting the default. The frictionless form takes no arguments at all: + +```ruby +Clickwrap.policy :withdrawal_authorization do + authorize :regulated_action, one_time: true, valid_for: 10.minutes + + record_ip_address + record_browser_user_agent + record_ip_geolocation +end +``` + +`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. + +The same declarations with the full record a reviewed team would want: ```ruby Clickwrap.policy :regulated_authorization do @@ -791,9 +807,11 @@ end Recorded values live in a separately encrypted annex with their own retention, so they can be deleted later without rewriting the core event payload. Core payloads have their own reviewed disposition path and leave a digest-linked tombstone. -Encryption is on by default and turning it off keeps its own ceremony — -`config.deliberately_store_request_evidence_unencrypted!(because: "…")` — because -that one is a real hazard, not paperwork. +Encryption is on by default for all three categories. Turning it off keeps its +own ceremony — you cannot reach `encrypt_recorded_ip_addresses = false` without +first writing `config.deliberately_store_request_evidence_unencrypted!` — but the +ceremony is the method name a reviewer finds in the diff, not a sentence the gem +makes you type. `because:` there is optional too. 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: diff --git a/guides/naming.md b/guides/naming.md index 1976563..7428d1d 100644 --- a/guides/naming.md +++ b/guides/naming.md @@ -291,7 +291,7 @@ object. | `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 | | `after_event_is_committed` | `after_commit` | Says which commit, and reads as a sentence | | `authorize_unredacted_request_evidence_access_with` | `access_control` | Long, and correct. It names exactly which access it authorizes | -| `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` | +| `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 | The last row is the pattern worth copying. When an option has a consequence somebody should have to think about, make the name carry the thinking. diff --git a/guides/request-evidence.md b/guides/request-evidence.md index 1a14774..b16593a 100644 --- a/guides/request-evidence.md +++ b/guides/request-evidence.md @@ -310,7 +310,26 @@ Clickwrap.policy :regulated_authorization do end ``` -Every keyword there is doing work: +Every keyword there is doing work, and **every one of them is optional**. The same three +declarations with nothing at all supplied are valid, and record the same fields: + +```ruby +Clickwrap.policy :frictionless_regulated_authorization do + authorize :regulated_action, one_time: true, valid_for: 10.minutes + + record_ip_address + record_browser_user_agent + record_ip_geolocation + retain_with :regulated_evidence +end +``` + +`record_ip_geolocation` with no field named records the coarse trio — country, region, city — +and nothing finer. Name even one field and the set is exactly what you named; name every field +`false` and Clickwrap refuses, because calling `record_ip_geolocation` and disabling everything +cannot mean anything (`do_not_record_ip_geolocation` is how to say that). + +What each keyword adds when you do supply it: - **`because:`** is the present purpose, in a sentence someone outside engineering can read. It 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: reviewed. What is refused is scaffolding text — `"TODO: ask legal"` is not a purpose. - **`legal_basis_reference:`** and **`data_protection_impact_assessment_reference:`** are host-supplied pointers to your own reviewed documents. Clickwrap stores them. It does not - read them, validate them, or endorse them. + read them, validate them, endorse them, or ever require them — nothing in the gem refuses a + recorded field for want of either, and nothing ever will. Your privacy policy owns the why; + the gem records the what. - **`delete_after:`** and **`retain_until:`** are both optional. When neither the policy, its retention class, nor the configuration names a schedule, the field keeps pace with the evidence it corroborates — the same posture the core event has — and the annex is stamped diff --git a/lib/clickwrap/configuration.rb b/lib/clickwrap/configuration.rb index a81558d..d0a7222 100644 --- a/lib/clickwrap/configuration.rb +++ b/lib/clickwrap/configuration.rb @@ -1227,15 +1227,19 @@ def reason_for_keeping_recorded_request_evidence_indefinitely(category) @keep_recorded_request_evidence_indefinitely[category.to_sym] end - def deliberately_store_request_evidence_unencrypted!(because:) - if because.to_s.strip.empty? - raise ConfigurationError, - "deliberately_store_request_evidence_unencrypted! needs a `because:` explaining " \ - "the reviewed decision." - end - + # The named escape hatch for turning encryption off. The ceremony is the + # method: you cannot reach `encrypt_recorded_* = false` without writing a + # line that says out loud what you are doing, and that line is what a + # reviewer finds in a diff. Since 0.3.1 the `because:` is optional — the + # gem records its own sentence when you do not write one — because the + # host's privacy policy owns the why, and demanding it twice never stopped + # anybody who had already typed this method name. + # + # Encryption itself is unchanged: on by default, for all three categories. + def deliberately_store_request_evidence_unencrypted!(because: nil) @deliberately_storing_request_evidence_unencrypted = true - @reason_for_storing_request_evidence_unencrypted = because + @reason_for_storing_request_evidence_unencrypted = + because.presence || Vocabulary::DEFAULT_REASON_FOR_STORING_REQUEST_EVIDENCE_UNENCRYPTED end def storing_request_evidence_unencrypted? = @deliberately_storing_request_evidence_unencrypted == true diff --git a/lib/clickwrap/dsl/policy_builder.rb b/lib/clickwrap/dsl/policy_builder.rb index bf1bfe8..60d8201 100644 --- a/lib/clickwrap/dsl/policy_builder.rb +++ b/lib/clickwrap/dsl/policy_builder.rb @@ -239,24 +239,34 @@ def do_not_record_browser_user_agent @request_evidence[:browser_user_agent] = RequestEvidencePolicy::NOT_RECORDED end - # Each IP-geolocation data field is named separately, because each one is - # a separate decision about what to keep about a person's network + # Each IP-geolocation data field can be named separately, because each one + # is a separate decision about what to keep about a person's network # context. `latitude_and_longitude` is one coupled choice: half a # coordinate is not a result. Whatever is enabled, the provider name, # source, estimated status, resolution time, and any accuracy or database # provenance the resolver supplies are stored with it automatically — a # policy cannot keep the coordinates and drop the uncertainty needed to # read them. - def record_ip_geolocation(country: false, region: false, city: false, postal_code: false, - latitude_and_longitude: false, timezone: false, continent: false, - metro_code: false, accuracy_radius_in_kilometers: false, + # + # Naming no field at all is the frictionless form: + # + # record_ip_geolocation + # + # It records the same coarse trio as + # `config.record_request_evidence_by_default` — country, region, city — + # and nothing finer, because that is what "IP geolocation" means in this + # gem when nobody narrows it. Naming even one field means you are + # choosing the set yourself, and then the set is exactly what you named. + def record_ip_geolocation(country: nil, region: nil, city: nil, postal_code: nil, + latitude_and_longitude: nil, timezone: nil, continent: nil, + metro_code: nil, accuracy_radius_in_kilometers: nil, using: nil, encrypted: nil, delete_after: nil, retain_until: nil, fail_if_unavailable: false, because: nil, legal_basis_reference: nil, data_protection_impact_assessment_reference: nil, **unknown_options) refuse_unknown_options!("record_ip_geolocation", unknown_options) - @ip_geolocation_fields = { + named = { "country" => country, "region" => region, "city" => city, @@ -267,6 +277,7 @@ def record_ip_geolocation(country: false, region: false, city: false, postal_cod "metro_code" => metro_code, "accuracy_radius_in_kilometers" => accuracy_radius_in_kilometers } + @ip_geolocation_fields = default_ip_geolocation_fields_when_none_named(named) @ip_geolocation_resolver_name = using @request_evidence[:ip_geolocation] = RequestEvidencePolicy::Setting.new( @@ -404,6 +415,20 @@ def application_default_legal_basis_reference(category) end end + # `nil` means "the policy did not mention this field"; `false` means "the + # policy named it and turned it off". The distinction is the whole reason + # the keywords default to nil: a policy that mentions nothing gets the + # coarse trio, and a policy that explicitly sets every field to false + # still reaches the coherence check that tells it to say + # `do_not_record_ip_geolocation` instead. + def default_ip_geolocation_fields_when_none_named(named) + return named.transform_values { |value| value == true } if named.any? { |_, value| !value.nil? } + + Vocabulary::IP_GEOLOCATION_DATA_FIELDS.to_h do |field| + [field, Vocabulary::COARSE_IP_GEOLOCATION_DATA_FIELDS.include?(field)] + end + end + def resolved_ip_geolocation_fields(setting) return {} unless setting.record? return @ip_geolocation_fields if @request_evidence.key?(:ip_geolocation) diff --git a/lib/clickwrap/request_evidence_policy.rb b/lib/clickwrap/request_evidence_policy.rb index a3d29b4..715fdbd 100644 --- a/lib/clickwrap/request_evidence_policy.rb +++ b/lib/clickwrap/request_evidence_policy.rb @@ -241,8 +241,11 @@ def validate_geolocation_coherence! if ip_geolocation.record? && enabled.empty? raise DefinitionError, - "Policy #{policy_key} calls `record_ip_geolocation` but enables no field. " \ - "Name the fields you actually need, for example `country: true`." + "Policy #{policy_key} calls `record_ip_geolocation` and then turns every field " \ + "off, which cannot mean anything. Name the fields you want, for example " \ + "`country: true`; call `record_ip_geolocation` with no fields at all for the " \ + "coarse country, region, and city; or say `do_not_record_ip_geolocation` if that " \ + "is what you meant." end if ip_geolocation.record? && diff --git a/lib/clickwrap/version.rb b/lib/clickwrap/version.rb index 7ab45f8..150e774 100644 --- a/lib/clickwrap/version.rb +++ b/lib/clickwrap/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Clickwrap - VERSION = "0.3.0" + VERSION = "0.3.1" # The canonical schema version for receipts, event digests, and presentation # manifests. This is deliberately independent of VERSION: gem releases may diff --git a/lib/clickwrap/vocabulary.rb b/lib/clickwrap/vocabulary.rb index 44f2616..6b76f5a 100644 --- a/lib/clickwrap/vocabulary.rb +++ b/lib/clickwrap/vocabulary.rb @@ -187,6 +187,15 @@ module Vocabulary DEFAULT_REASON_FOR_KEEPING_REQUEST_EVIDENCE_INDEFINITELY = "Corroboration lives as long as the evidence it corroborates" + # The reason recorded when a host turns off encryption for request evidence + # without writing their own. Calling the method is still the ceremony — + # `deliberately_store_request_evidence_unencrypted!` is a sentence a + # reviewer finds in a diff and cannot misread — but the gem no longer + # demands the sentence be phrased twice. + DEFAULT_REASON_FOR_STORING_REQUEST_EVIDENCE_UNENCRYPTED = + "The application deliberately stores request evidence unencrypted; the reason lives " \ + "outside Clickwrap" + # Provenance that travels with any stored IP-geolocation result. A policy # cannot keep provider-derived coordinates while stripping the uncertainty # needed to interpret them. diff --git a/lib/generators/clickwrap/install_generator.rb b/lib/generators/clickwrap/install_generator.rb index b09dbf9..ec1624f 100644 --- a/lib/generators/clickwrap/install_generator.rb +++ b/lib/generators/clickwrap/install_generator.rb @@ -1160,23 +1160,30 @@ def validate_request_evidence_choices! validate_ip_geolocation_resolver_class_name! end + # A missing purpose and a missing deletion period are both fine, and the + # generated file simply omits those lines: the gem records its own stated + # purpose and keeps the field as long as the evidence it corroborates. + # Scaffolding text is the one thing still refused, because a `TODO` the + # installer writes into a shipped initializer is worse than no line at + # all — the gem would reject it at boot anyway. def validate_enabled_category!(label, enabled:, because:, delete_after_days:, reason_option:, retention_option:) return unless enabled - unless Clickwrap::ReviewedText.present_and_reviewed?(because) + if Clickwrap::ReviewedText.placeholder?(because) raise Thor::Error, - "Clickwrap cannot enable #{label} with a blank or scaffolding reason. " \ - "Give the application's reviewed, present-tense reason with " \ - "#{reason_option}=\"...\", or turn that category off. No files were written." + "Clickwrap cannot enable #{label} with a scaffolding reason " \ + "(#{because.inspect}). Give the application's reviewed, present-tense reason " \ + "with #{reason_option}=\"...\", or omit it entirely and let Clickwrap record " \ + "its own stated purpose. No files were written." end - return if delete_after_days.positive? + return unless delete_after_days.negative? raise Thor::Error, - "Clickwrap cannot enable #{label} without a positive deletion period. " \ - "Set #{retention_option}=DAYS to the period your application reviewed, or " \ - "turn that category off. No files were written." + "#{retention_option} cannot be negative (got #{delete_after_days}). Give the " \ + "number of days your application reviewed, or omit it and #{label} will keep " \ + "pace with the evidence it corroborates. No files were written." end def validate_ip_geolocation_coordinates! diff --git a/lib/generators/clickwrap/templates/initializer.rb.erb b/lib/generators/clickwrap/templates/initializer.rb.erb index 46d03b8..4164a46 100644 --- a/lib/generators/clickwrap/templates/initializer.rb.erb +++ b/lib/generators/clickwrap/templates/initializer.rb.erb @@ -287,19 +287,25 @@ Clickwrap.configure do |config| # event — the historical agreement, declaration, or authorization stays intact # and verifiable without it. -<%- if record_ip_addresses? -%> +<%- if record_ip_addresses? && reason_for_recording_ip_addresses.present? -%> config.reason_for_recording_ip_addresses_by_default = <%= reason_for_recording_ip_addresses.inspect %> +<%- end -%> +<%- if record_ip_addresses? && delete_recorded_ip_addresses_after_days.positive? -%> config.delete_recorded_ip_addresses_after = <%= delete_recorded_ip_addresses_after_days %>.days <%- end -%> -<%- if record_browser_user_agents? -%> +<%- if record_browser_user_agents? && reason_for_recording_browser_user_agents.present? -%> config.reason_for_recording_browser_user_agents_by_default = <%= reason_for_recording_browser_user_agents.inspect %> +<%- end -%> +<%- if record_browser_user_agents? && delete_recorded_browser_user_agents_after_days.positive? -%> config.delete_recorded_browser_user_agents_after = <%= delete_recorded_browser_user_agents_after_days %>.days <%- end -%> -<%- if any_ip_geolocation_field? -%> +<%- if any_ip_geolocation_field? && reason_for_recording_ip_geolocation.present? -%> config.reason_for_recording_ip_geolocation_by_default = <%= reason_for_recording_ip_geolocation.inspect %> +<%- end -%> +<%- if any_ip_geolocation_field? && delete_recorded_ip_geolocation_after_days.positive? -%> config.delete_recorded_ip_geolocation_after = <%= delete_recorded_ip_geolocation_after_days %>.days <%- end -%> <%- if records_any_request_evidence? -%> @@ -332,8 +338,9 @@ Clickwrap.configure do |config| # Turning one of these off puts the raw value in plain text in your database, # and therefore in every ordinary backup and database dump. It is allowed, # because some applications have a reviewed reason, but it is never a quiet - # one-character change: Clickwrap refuses `false` until you have said why in - # `config.deliberately_store_request_evidence_unencrypted!(because: "…")`. + # one-character change: Clickwrap refuses `false` until you have written + # `config.deliberately_store_request_evidence_unencrypted!` above it. Writing + # that line IS the whole ceremony — its `because:` is optional. # # config.encrypt_recorded_ip_addresses = true # config.encrypt_recorded_browser_user_agents = true diff --git a/test/configuration_test.rb b/test/configuration_test.rb index bdff105..f04d748 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -309,6 +309,10 @@ def initialize(id) # --- The named escape hatch ------------------------------------------------- test "turning encryption off is a sentence a reviewer can find, not a false" do + # The ceremony is the method name, and it survives: `= false` alone still + # cannot reach the setting. What no longer survives is being asked to + # phrase the reason twice — the method records the gem's own when the host + # writes none. error = assert_raises(Clickwrap::ConfigurationError) do Clickwrap.config.encrypt_recorded_ip_addresses = false end @@ -316,20 +320,34 @@ def initialize(id) assert_match(/plain text in your database/, error.message) assert_match(/deliberately_store_request_evidence_unencrypted!/, error.message) - assert_raises(Clickwrap::ConfigurationError) do - Clickwrap.config.deliberately_store_request_evidence_unencrypted!(because: " ") - end + Clickwrap.config.deliberately_store_request_evidence_unencrypted! + Clickwrap.config.encrypt_recorded_ip_addresses = false + + assert_not Clickwrap.config.encrypt_recorded_ip_addresses + assert Clickwrap.config.storing_request_evidence_unencrypted? + assert_equal Clickwrap::Vocabulary::DEFAULT_REASON_FOR_STORING_REQUEST_EVIDENCE_UNENCRYPTED, + Clickwrap.config.reason_for_storing_request_evidence_unencrypted + end + test "a host's own reason for unencrypted storage stays their own words" do Clickwrap.config.deliberately_store_request_evidence_unencrypted!( because: "Reviewed: this deployment encrypts at the storage layer" ) - Clickwrap.config.encrypt_recorded_ip_addresses = false + Clickwrap.config.encrypt_recorded_browser_user_agents = false - assert_not Clickwrap.config.encrypt_recorded_ip_addresses - assert Clickwrap.config.storing_request_evidence_unencrypted? + assert_not Clickwrap.config.encrypt_recorded_browser_user_agents assert_match(/storage layer/, Clickwrap.config.reason_for_storing_request_evidence_unencrypted) end + test "encryption is on by default for all three categories, switch or no switch" do + Clickwrap.configure { |config| config.record_request_evidence_by_default = true } + + assert Clickwrap.config.encrypt_recorded_ip_addresses + assert Clickwrap.config.encrypt_recorded_browser_user_agents + assert Clickwrap.config.encrypt_recorded_ip_geolocation + assert_not Clickwrap.config.storing_request_evidence_unencrypted? + end + test "there is no switch whose name hides what it collects" do # Deliberately absent. Each of these names either claims a legal outcome no # runtime flag can deliver, or describes its collection so vaguely that a diff --git a/test/generators/install_generator_test.rb b/test/generators/install_generator_test.rb index d793064..c4d0575 100644 --- a/test/generators/install_generator_test.rb +++ b/test/generators/install_generator_test.rb @@ -486,17 +486,46 @@ class InstallGeneratorTest < Rails::Generators::TestCase end end - test "an incomplete enabled category is refused before any file is written" do + test "enabling a category with no purpose and no period writes the file anyway" do + run_generator %w[--skip-questions --record-ip-addresses-by-default] + + assert_file "config/initializers/clickwrap.rb" do |initializer| + assert_match(/config\.record_ip_address_by_default = true/, initializer) + refute_match(/config\.reason_for_recording_ip_addresses_by_default =/, initializer) + refute_match(/config\.delete_recorded_ip_addresses_after =/, initializer) + end + end + + test "scaffolding text is refused before any file is written" do stderr = capture(:stderr) do - run_generator %w[--skip-questions --record-ip-addresses-by-default] + run_generator [ + "--skip-questions", + "--record-ip-addresses-by-default", + "--reason-for-recording-ip-addresses-by-default=TODO: ask legal" + ] end - assert_match(/cannot enable IP addresses with a blank or scaffolding reason/i, stderr) + assert_match(/cannot enable IP addresses with a scaffolding reason/i, stderr) + assert_match(/let Clickwrap record its own stated purpose/, stderr) assert_match(/No files were written/, stderr) assert_no_file "config/initializers/clickwrap.rb" assert_empty Dir.glob(File.join(destination_root, "db/migrate/*_create_clickwrap_tables.rb")) end + test "a negative deletion period is refused before any file is written" do + stderr = capture(:stderr) do + run_generator [ + "--skip-questions", + "--record-ip-addresses-by-default", + "--delete-recorded-ip-addresses-after-days=-30" + ] + end + + assert_match(/cannot be negative/, stderr) + assert_match(/No files were written/, stderr) + assert_no_file "config/initializers/clickwrap.rb" + end + test "IP geolocation requires explicit uncertainty and a resolver class" do common = [ "--skip-questions", diff --git a/test/request_evidence_test.rb b/test/request_evidence_test.rb index e2c3d6f..72fe8db 100644 --- a/test/request_evidence_test.rb +++ b/test/request_evidence_test.rb @@ -116,6 +116,90 @@ class RequestEvidenceTest < ActiveSupport::TestCase assert_not policy.request_evidence.records_browser_user_agent? end + # --- Zero-keyword declarations ---------------------------------------------- + + test "every record_ verb works with no keyword arguments at all" do + policy = Clickwrap.policy :zero_keyword_request_evidence do + agree_to :terms + record_ip_address + record_browser_user_agent + record_ip_geolocation + end + + request_evidence = policy.request_evidence + assert request_evidence.records_ip_address? + assert request_evidence.records_browser_user_agent? + assert request_evidence.records_ip_geolocation? + + # No field named means the same coarse trio the one switch turns on. + assert_equal %w[country region city], request_evidence.enabled_ip_geolocation_fields + + Clickwrap::RequestEvidencePolicy::FIELD_CATEGORIES.each do |category| + setting = request_evidence.setting_for(category) + assert_equal Clickwrap::Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE, setting.because + assert_equal "gem_default", request_evidence.purpose_source_for(category) + assert_nil setting.legal_basis_reference + assert_nil setting.delete_after + assert_nil setting.retain_until + end + end + + test "a zero-keyword declaration captures and stores exactly the coarse trio" do + configure_static_resolver! + Clickwrap.policy(:zero_keyword_capture) do + agree_to :terms + record_ip_address + record_ip_geolocation + end + Clickwrap::Services::ValidatePolicyReferences.call + + receipt = submit_clickwrap(:zero_keyword_capture, actor: @user, http_request: @http_request) + annex = receipt.event.reload.request_evidence + + assert_equal "203.0.113.7", annex.ip_address + assert annex.recorded_ip_geolocation_country? + assert annex.recorded_ip_geolocation_city? + assert_not annex.recorded_ip_geolocation_latitude_and_longitude? + assert_not annex.recorded_ip_geolocation_postal_code? + end + + test "naming even one geolocation field means you chose the whole set" do + policy = Clickwrap.policy :one_named_geolocation_field do + agree_to :terms + record_ip_geolocation country: true + end + + assert_equal %w[country], policy.request_evidence.enabled_ip_geolocation_fields + end + + test "naming every geolocation field false is refused as meaningless" do + error = assert_raises(Clickwrap::DefinitionError) do + Clickwrap.policy :all_geolocation_fields_off do + agree_to :terms + record_ip_geolocation country: false, region: false, city: false + end + end + + assert_match(/turns every field off/, error.message) + assert_match(/do_not_record_ip_geolocation/, error.message) + end + + test "a legal basis reference is never required, at either level" do + Clickwrap.configure { |config| config.record_request_evidence_by_default = true } + switched = Clickwrap.policy(:no_legal_basis_by_default) { agree_to :terms } + + assert switched.request_evidence.records_ip_address? + assert_nil switched.request_evidence.ip_address.legal_basis_reference + + named = Clickwrap.policy :no_legal_basis_named do + agree_to :terms + record_ip_address because: "Investigate disputed acceptance" + end + + assert_nil named.request_evidence.ip_address.legal_basis_reference + assert_nil named.request_evidence.ip_address.data_protection_impact_assessment_reference + end + # --- Gem-supplied defaults -------------------------------------------------- test "turning a default on without a purpose records the purpose Clickwrap states" do