clickwrap 0.0.0 → 0.1.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.
- checksums.yaml +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +612 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +241 -0
- data/app/controllers/clickwrap/application_controller.rb +79 -0
- data/app/controllers/clickwrap/captures_controller.rb +145 -0
- data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
- data/app/controllers/clickwrap/receipts_controller.rb +115 -0
- data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
- data/app/helpers/clickwrap/engine_helper.rb +97 -0
- data/app/views/clickwrap/captures/show.html.erb +34 -0
- data/app/views/clickwrap/receipts/index.html.erb +38 -0
- data/app/views/clickwrap/receipts/show.html.erb +91 -0
- data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
- data/app/views/clickwrap/shared/_fields.html.erb +100 -0
- data/app/views/clickwrap/shared/_statement.html.erb +105 -0
- data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
- data/config/locales/en.yml +160 -0
- data/config/locales/es.yml +138 -0
- data/config/routes.rb +41 -0
- data/exe/clickwrap +374 -0
- data/guides/README.md +30 -0
- data/guides/accessibility.md +249 -0
- data/guides/consent-and-lifecycle.md +299 -0
- data/guides/integrating.md +610 -0
- data/guides/integrity.md +212 -0
- data/guides/migrating.md +335 -0
- data/guides/naming.md +320 -0
- data/guides/organizations.md +320 -0
- data/guides/receipts-and-verification.md +415 -0
- data/guides/request-evidence.md +512 -0
- data/guides/retention-and-legal-holds.md +438 -0
- data/lib/clickwrap/actor_proxy.rb +147 -0
- data/lib/clickwrap/anonymous_actor.rb +47 -0
- data/lib/clickwrap/authority.rb +174 -0
- data/lib/clickwrap/canonical_json.rb +216 -0
- data/lib/clickwrap/capture/event_builder.rb +220 -0
- data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
- data/lib/clickwrap/capture.rb +650 -0
- data/lib/clickwrap/configuration.rb +1129 -0
- data/lib/clickwrap/controller_helpers.rb +758 -0
- data/lib/clickwrap/current_state.rb +282 -0
- data/lib/clickwrap/digest.rb +125 -0
- data/lib/clickwrap/doctor.rb +418 -0
- data/lib/clickwrap/document_definition.rb +255 -0
- data/lib/clickwrap/document_renderer.rb +83 -0
- data/lib/clickwrap/document_renderers/markdown.rb +175 -0
- data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
- data/lib/clickwrap/dsl/policy_builder.rb +462 -0
- data/lib/clickwrap/dsl/retention_builder.rb +89 -0
- data/lib/clickwrap/durable_commit_callback.rb +37 -0
- data/lib/clickwrap/engine.rb +184 -0
- data/lib/clickwrap/errors.rb +181 -0
- data/lib/clickwrap/form_builder_extensions.rb +341 -0
- data/lib/clickwrap/front_matter.rb +67 -0
- data/lib/clickwrap/identifier.rb +112 -0
- data/lib/clickwrap/import/external_receipt.rb +241 -0
- data/lib/clickwrap/import/fine_print.rb +290 -0
- data/lib/clickwrap/import/legacy.rb +450 -0
- data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
- data/lib/clickwrap/integrity/anchor.rb +130 -0
- data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
- data/lib/clickwrap/integrity/attestor.rb +221 -0
- data/lib/clickwrap/integrity/chain.rb +313 -0
- data/lib/clickwrap/integrity/timestamp.rb +143 -0
- data/lib/clickwrap/ip_geolocation/location.rb +112 -0
- data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
- data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
- data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
- data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
- data/lib/clickwrap/ip_geolocation.rb +16 -0
- data/lib/clickwrap/lifecycle.rb +534 -0
- data/lib/clickwrap/linter.rb +382 -0
- data/lib/clickwrap/localized_text.rb +101 -0
- data/lib/clickwrap/macros.rb +203 -0
- data/lib/clickwrap/models/application_record.rb +20 -0
- data/lib/clickwrap/models/chain_head.rb +79 -0
- data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
- data/lib/clickwrap/models/disposition_plan.rb +208 -0
- data/lib/clickwrap/models/document.rb +46 -0
- data/lib/clickwrap/models/document_version.rb +163 -0
- data/lib/clickwrap/models/event.rb +743 -0
- data/lib/clickwrap/models/event_document.rb +79 -0
- data/lib/clickwrap/models/event_statement.rb +92 -0
- data/lib/clickwrap/models/external_action.rb +150 -0
- data/lib/clickwrap/models/integrity_attestation.rb +90 -0
- data/lib/clickwrap/models/legal_hold.rb +81 -0
- data/lib/clickwrap/models/policy_revision.rb +115 -0
- data/lib/clickwrap/models/presentation.rb +59 -0
- data/lib/clickwrap/models/receipt_access.rb +53 -0
- data/lib/clickwrap/models/recording_sequence.rb +21 -0
- data/lib/clickwrap/models/request_evidence.rb +378 -0
- data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
- data/lib/clickwrap/models/statement_state.rb +130 -0
- data/lib/clickwrap/pending_receipt.rb +177 -0
- data/lib/clickwrap/policy.rb +283 -0
- data/lib/clickwrap/presentation_manifest.rb +210 -0
- data/lib/clickwrap/presenter.rb +716 -0
- data/lib/clickwrap/privacy.rb +419 -0
- data/lib/clickwrap/protected_outcome.rb +120 -0
- data/lib/clickwrap/receipt.rb +606 -0
- data/lib/clickwrap/receipt_html.rb +235 -0
- data/lib/clickwrap/receipt_verifier.rb +978 -0
- data/lib/clickwrap/reference.rb +44 -0
- data/lib/clickwrap/registration.rb +236 -0
- data/lib/clickwrap/registry.rb +54 -0
- data/lib/clickwrap/remediation_token.rb +155 -0
- data/lib/clickwrap/request_evidence_extractor.rb +590 -0
- data/lib/clickwrap/request_evidence_policy.rb +261 -0
- data/lib/clickwrap/retention/applier.rb +231 -0
- data/lib/clickwrap/retention/disposition.rb +221 -0
- data/lib/clickwrap/retention/planner.rb +502 -0
- data/lib/clickwrap/retention_class.rb +97 -0
- data/lib/clickwrap/reviewed_text.rb +28 -0
- data/lib/clickwrap/schema_requirements.rb +196 -0
- data/lib/clickwrap/services/authorize_external_action.rb +149 -0
- data/lib/clickwrap/services/load_policies.rb +69 -0
- data/lib/clickwrap/services/publish_documents.rb +251 -0
- data/lib/clickwrap/services/validate_policy_references.rb +166 -0
- data/lib/clickwrap/statement.rb +248 -0
- data/lib/clickwrap/subject_fingerprint.rb +28 -0
- data/lib/clickwrap/submission.rb +169 -0
- data/lib/clickwrap/system_actor.rb +31 -0
- data/lib/clickwrap/test_helpers.rb +676 -0
- data/lib/clickwrap/testing.rb +211 -0
- data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
- data/lib/clickwrap/verification.rb +504 -0
- data/lib/clickwrap/version.rb +12 -1
- data/lib/clickwrap/view_helpers.rb +190 -0
- data/lib/clickwrap/vocabulary.rb +294 -0
- data/lib/clickwrap.rb +497 -7
- data/lib/generators/clickwrap/document_generator.rb +164 -0
- data/lib/generators/clickwrap/hardening_generator.rb +177 -0
- data/lib/generators/clickwrap/install_generator.rb +1287 -0
- data/lib/generators/clickwrap/link_generator.rb +56 -0
- data/lib/generators/clickwrap/policy_generator.rb +118 -0
- data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
- data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
- data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
- data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
- data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
- data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
- data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
- data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
- data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
- data/lib/generators/clickwrap/views_generator.rb +101 -0
- data/lib/tasks/clickwrap.rake +569 -0
- metadata +311 -16
data/README.md
CHANGED
|
@@ -1,571 +1,553 @@
|
|
|
1
|
-
# ☑️ `clickwrap`
|
|
1
|
+
# ☑️ `clickwrap` - Make your Rails users accept your Terms and legal documents
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
[](https://badge.fury.io/rb/clickwrap) [](https://github.com/rameerez/clickwrap/actions)
|
|
4
|
+
|
|
5
|
+
> [!TIP]
|
|
6
|
+
> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=clickwrap)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks — including versioned Terms and Privacy Notice acceptance powered by this gem. Go [check it out](https://railsfast.com/?ref=clickwrap)!
|
|
5
7
|
|
|
6
|
-
`clickwrap`
|
|
8
|
+
`clickwrap` makes your Rails users accept your Terms of Service, acknowledge your Privacy Notice, give (and withdraw) consent, make declarations, and authorize one-time actions — and keeps evidence of all of it that you can still reproduce and verify years later.
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
✨ Perfect for SaaS signups, marketplaces, fintech payouts, health apps, and any Rails app where "the user agreed to this" needs to be provable long after the fact.
|
|
11
|
+
|
|
12
|
+
Ordinary Terms acceptance is one line in your signup form:
|
|
9
13
|
|
|
10
14
|
```erb
|
|
11
15
|
<%= form.clickwrap :signup, submit: "Create account" %>
|
|
12
16
|
```
|
|
13
17
|
|
|
14
|
-
|
|
18
|
+
…and it renders one line on the page. One checkbox, one sentence, your legal pages linked inside it:
|
|
19
|
+
|
|
20
|
+
> ☐ I agree to the [Terms of Service](#) and I acknowledge the [Privacy Policy](#).
|
|
21
|
+
|
|
22
|
+
No "Required" flag, no "(opens in a new tab)" printed beside every link, no version label sitting under a checkbox. Behind that single control, the receipt still records two separate acts — an *agreement* to the Terms and an *acknowledgment* of the Privacy Notice — with their own versions, digests, and lifecycles.
|
|
23
|
+
|
|
24
|
+
And when an action is consequential enough that it must never happen without its evidence (a payout, a data handoff, a contract), the evidence and the action commit in the same database transaction:
|
|
15
25
|
|
|
16
26
|
```ruby
|
|
17
|
-
|
|
18
|
-
:
|
|
19
|
-
actor: current_user,
|
|
20
|
-
subject: withdrawal,
|
|
21
|
-
http_request: request,
|
|
22
|
-
submission: clickwrap_submission
|
|
23
|
-
) do |pending_receipt|
|
|
27
|
+
Clickwrap.capture_and!(:withdrawal_authorization, actor: current_user, subject: withdrawal,
|
|
28
|
+
http_request: request, submission: clickwrap_submission) do |pending_receipt|
|
|
24
29
|
withdrawal.submit!(authorized_by_clickwrap_event: pending_receipt.event_id)
|
|
25
30
|
end
|
|
26
31
|
```
|
|
27
32
|
|
|
28
|
-
If evidence
|
|
29
|
-
|
|
30
|
-
No JavaScript package. No Redis. No external account. No legal-document vendor. No required per-event API call. No required background job. Just Rails, your database, and an API that reads like plain English.
|
|
33
|
+
If the evidence can't be recorded, the action doesn't happen. If the action fails, the evidence doesn't pretend it succeeded.
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
> **Building a new Rails product?** [RailsFast](https://railsfast.com/?ref=clickwrap) ships the conventional signup integration, so new applications start with versioned Terms, a distinct Privacy Notice acknowledgment, atomic evidence, and receipts instead of inventing an `accepted_terms_at` column.
|
|
35
|
+
No JavaScript package. No Redis. No background jobs. No external accounts or per-event API calls. No legal-document vendor. Just Rails, your database, and a DSL that reads like plain English.
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
> [!IMPORTANT]
|
|
38
|
+
> **Status: built and tested, not yet proven in production.** Everything in this README is implemented and covered by the test suite, but the gem hasn't been through its planned production integrations, an unfamiliar-developer usability test, or legal review of its default wording yet. Treat it as a release candidate for evaluation — don't put it under a payout flow just yet. The [stability promise](#stability-and-upgrade-promise) applies from 0.1.0 onward.
|
|
36
39
|
|
|
37
|
-
|
|
40
|
+
## 👨💻 Example
|
|
38
41
|
|
|
39
|
-
|
|
40
|
-
bundle add clickwrap
|
|
41
|
-
bin/rails generate clickwrap:install
|
|
42
|
-
bin/rails db:migrate
|
|
43
|
-
```
|
|
42
|
+
Define your documents and a policy in plain Ruby. Each document points at the file that *is* your legal text — and that file names its own version, in the front matter it probably already has. The top of `app/content/legal/terms.md`:
|
|
44
43
|
|
|
45
|
-
|
|
44
|
+
```markdown
|
|
45
|
+
---
|
|
46
|
+
title: Terms of Service
|
|
47
|
+
last_updated: 2026-08-15
|
|
48
|
+
---
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
# Terms of Service
|
|
51
|
+
```
|
|
48
52
|
|
|
49
53
|
```ruby
|
|
54
|
+
# clickwrap-doc-test: syntax-only — terms.md and privacy.md are files in your app
|
|
50
55
|
# config/clickwrap.rb
|
|
51
56
|
Clickwrap.document :terms,
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
from: Rails.root.join("app/content/legal/terms.md"),
|
|
58
|
+
link: "/legal/terms"
|
|
54
59
|
|
|
55
60
|
Clickwrap.document :privacy_notice,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
Clickwrap.policy :signup do
|
|
60
|
-
agree_to :terms
|
|
61
|
-
acknowledge :privacy_notice
|
|
62
|
-
end
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
Tell Clickwrap which records can act:
|
|
66
|
-
|
|
67
|
-
```ruby
|
|
68
|
-
# app/models/user.rb
|
|
69
|
-
class User < ApplicationRecord
|
|
70
|
-
has_clickwraps
|
|
71
|
-
end
|
|
61
|
+
from: Rails.root.join("app/content/legal/privacy.md"),
|
|
62
|
+
link: "/legal/privacy"
|
|
72
63
|
```
|
|
73
64
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
```erb
|
|
77
|
-
<%= form_with model: resource do |form| %>
|
|
78
|
-
<%# email, password, etc. %>
|
|
79
|
-
|
|
80
|
-
<%= form.clickwrap :signup, submit: "Create account" %>
|
|
81
|
-
<% end %>
|
|
82
|
-
```
|
|
65
|
+
`from:` is the bytes Clickwrap freezes, digests, and keeps as evidence. `link:` is where a *person* reads them — your own formatted page, with your typography and your navigation — and it is the path Clickwrap both renders and signs, so the receipt never cites a different target from the link somebody pressed. Leave `link:` off and the sentence links to the engine's rendering of the exact published version instead. (Your page shows whatever is current; that trade is yours to make, and it is written down in the declaration where a reviewer will see it.)
|
|
83
66
|
|
|
84
|
-
|
|
67
|
+
Changing your Terms is then one edit in one file: new words, new `last_updated:`, publish. There is no second copy of the version label anywhere to drift — and a file carrying neither `clickwrap_version:` nor `last_updated:` fails the boot with a sentence instead of getting a label Clickwrap invented. Sources that can't carry front matter still name their label the explicit way:
|
|
85
68
|
|
|
86
|
-
```
|
|
87
|
-
|
|
69
|
+
```ruby
|
|
70
|
+
Clickwrap.document :handbook,
|
|
71
|
+
version: "2026-08-15",
|
|
72
|
+
from: Rails.root.join("app/content/legal/handbook.pdf")
|
|
88
73
|
```
|
|
89
74
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
At first render there is no persisted user yet. Clickwrap does not pretend otherwise: it binds the presentation to a short-lived prospective-actor registration flow, then the authentication adapter binds the resulting account to that presentation inside the same transaction. The receipt identifies the attribution method as account registration, not an authenticated session.
|
|
75
|
+
#### Reading that front matter yourself: `Clickwrap::FrontMatter`
|
|
93
76
|
|
|
94
|
-
|
|
77
|
+
Your own pages usually need the same two answers, and it is the same block, so use the same reader rather than writing a third one:
|
|
95
78
|
|
|
96
79
|
```ruby
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
user.clickwraps.current_for?(:signup) # => true
|
|
100
|
-
|
|
101
|
-
receipt = user.clickwraps.receipts.last
|
|
102
|
-
receipt.event_id # => "01K2..."
|
|
103
|
-
receipt.verify.success? # => true
|
|
104
|
-
receipt.to_canonical_json
|
|
105
|
-
receipt.to_html
|
|
80
|
+
Clickwrap::FrontMatter.version_label_in(File.read(path)) # => "2026-08-15", or nil
|
|
81
|
+
Clickwrap::FrontMatter.strip(File.read(path)) # the body, without the block
|
|
106
82
|
```
|
|
107
83
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
Everything below is depth, not setup tax.
|
|
84
|
+
It reads a leading `---` block closed by `---` or `...`, takes simple top-level `key: value` lines only, and answers with `clickwrap_version:` when present, `last_updated:` otherwise — a same-day correction that still changes bytes needs a fresh label while the date readers see stays put. Two details are exactly where hand-rolled readers diverge, so they are worth naming: a quoted value has its quotes removed, and an unquoted trailing YAML comment is not part of the value, so `last_updated: 2026-11-01 # was 2026-08-15` is the label `2026-11-01`, precisely as YAML reads it.
|
|
111
85
|
|
|
112
|
-
|
|
86
|
+
`strip` removes the block from the *rendered* representation only. The source digest still covers the exact file bytes, front matter included, because that is what the file was.
|
|
113
87
|
|
|
114
|
-
|
|
115
|
-
- use [`capture_and!`](#capture-evidence-and-the-protected-action-together) for consequential same-database actions;
|
|
116
|
-
- read [consent](#consent-that-can-actually-be-withdrawn), [declarations](#expiring-and-corrected-declarations), or [one-time authorization](#narrow-one-time-authorizations) for richer lifecycles;
|
|
117
|
-
- configure [optional request evidence](#optional-request-evidence-private-by-default) only after reading its privacy boundaries;
|
|
118
|
-
- use [receipts](#receipts-answer-show-me-exactly-what-happened), [retention](#retention-deletion-and-legal-holds-are-first-class), and [integrity tiers](#progressive-honest-integrity) when the audit trail matters; or
|
|
119
|
-
- jump to [the complete initializer](#the-generated-initializer-explains-itself) to see every default together.
|
|
120
|
-
|
|
121
|
-
---
|
|
88
|
+
Then say how long the evidence lives and what the server offers:
|
|
122
89
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
- Which exact version did this person agree to?
|
|
128
|
-
- What did the page actually say beside the control and submit button?
|
|
129
|
-
- Was the checkbox initially empty and required on the server?
|
|
130
|
-
- Did the account, payout, declaration, or provider handoff succeed without its evidence?
|
|
131
|
-
- Was this consent later withdrawn?
|
|
132
|
-
- Had this declaration expired?
|
|
133
|
-
- Did this authorization cover this exact transaction, or was it replayed for another one?
|
|
134
|
-
- Can an auditor reproduce the document without checking out historical application code?
|
|
135
|
-
- Can optional personal request evidence be deleted without rewriting the historical event?
|
|
136
|
-
- Can the exported receipt still be verified after several gem and Rails upgrades?
|
|
90
|
+
```ruby
|
|
91
|
+
Clickwrap.retention :ordinary_agreement_evidence do
|
|
92
|
+
retain_core_event_for 6.years
|
|
93
|
+
end
|
|
137
94
|
|
|
138
|
-
|
|
95
|
+
Clickwrap.policy :signup do
|
|
96
|
+
agree_to :terms
|
|
97
|
+
acknowledge :privacy_notice
|
|
139
98
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
terms_version
|
|
143
|
-
an audit log
|
|
144
|
-
a few hidden form fields
|
|
145
|
-
an after_create callback
|
|
146
|
-
some IP-address columns
|
|
147
|
-
several domain-specific "confirmed_at" timestamps
|
|
99
|
+
retain_with :ordinary_agreement_evidence
|
|
100
|
+
end
|
|
148
101
|
```
|
|
149
102
|
|
|
150
|
-
|
|
103
|
+
(Yes, the payload-retention decision is mandatory — `clickwrap` will not silently
|
|
104
|
+
default captured evidence or request evidence to "keep forever" and will not pick
|
|
105
|
+
a period for you. A minimal, digest-linked disposition tombstone remains after a
|
|
106
|
+
reviewed core deletion so the deletion itself does not become an unexplained hole.)
|
|
151
107
|
|
|
152
|
-
|
|
108
|
+
Add one macro to your model:
|
|
153
109
|
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
+
|
|
159
|
-
exact presentation
|
|
160
|
-
+
|
|
161
|
-
explicit actor action
|
|
162
|
-
+
|
|
163
|
-
atomic protected outcome
|
|
164
|
-
+
|
|
165
|
-
append-only lifecycle
|
|
166
|
-
=
|
|
167
|
-
reproducible receipt
|
|
110
|
+
```ruby
|
|
111
|
+
class User < ApplicationRecord
|
|
112
|
+
has_clickwraps
|
|
113
|
+
end
|
|
168
114
|
```
|
|
169
115
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
## Six verbs, six honest meanings
|
|
116
|
+
Render the line and the submit button as one bound presentation:
|
|
173
117
|
|
|
174
|
-
|
|
118
|
+
```erb
|
|
119
|
+
<%= form.clickwrap :signup, submit: "Create account" %>
|
|
120
|
+
```
|
|
175
121
|
|
|
176
|
-
|
|
177
|
-
|---|---|---|---|
|
|
178
|
-
| `agree_to` | `agreement` | Assent to contractual terms | agreed → superseded/new version |
|
|
179
|
-
| `acknowledge` | `acknowledgment` | Affirmative receipt or awareness of a notice/risk | acknowledged → superseded/expired |
|
|
180
|
-
| `consent_to` | `consent` | Purpose-specific permission where consent is the host’s chosen basis | granted → withdrawn/renewed/scope changed |
|
|
181
|
-
| `declare` | `declaration` | A factual statement made by the actor | declared → corrected/superseded/expired |
|
|
182
|
-
| `attest` | `attestation` | An operational fact affirmed by an authorized actor | attested → corrected/superseded |
|
|
183
|
-
| `authorize` | `authorization` | Narrow permission bound to a protected action | authorized → consumed/revoked/expired |
|
|
122
|
+
> ☐ I agree to the [Terms of Service](#) and I acknowledge the [Privacy Policy](#).
|
|
184
123
|
|
|
185
|
-
|
|
124
|
+
From that moment on, you can ask readable questions everywhere:
|
|
186
125
|
|
|
187
126
|
```ruby
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
consent_to :product_updates, optional: true
|
|
192
|
-
declare :information_is_accurate
|
|
193
|
-
attest :bank_transfer_was_accepted
|
|
194
|
-
authorize :withdrawal, one_time: true, valid_for: 10.minutes
|
|
195
|
-
end
|
|
127
|
+
user.clickwraps.agreed_to?(:terms) # => true
|
|
128
|
+
user.clickwraps.acknowledged?(:privacy_notice) # => true
|
|
129
|
+
user.clickwraps.current_for?(:signup) # => true
|
|
196
130
|
```
|
|
197
131
|
|
|
198
|
-
|
|
132
|
+
And every acceptance produces a receipt you can export and verify — even outside your app, without your app's source code:
|
|
199
133
|
|
|
200
|
-
|
|
134
|
+
```ruby
|
|
135
|
+
receipt = user.clickwraps.receipts.last
|
|
136
|
+
receipt.verify.success? # => true
|
|
137
|
+
receipt.to_canonical_json # canonical JSON for the standalone verifier
|
|
138
|
+
receipt.to_html # human-readable version of the same evidence
|
|
139
|
+
```
|
|
201
140
|
|
|
202
|
-
|
|
141
|
+
Sounds good? Let's get started!
|
|
203
142
|
|
|
204
|
-
##
|
|
143
|
+
## Quick start
|
|
205
144
|
|
|
206
|
-
|
|
145
|
+
Add the gem and run the installer:
|
|
207
146
|
|
|
208
147
|
```ruby
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
locale: :en,
|
|
212
|
-
effective_at: Time.utc(2026, 8, 15),
|
|
213
|
-
from: Rails.root.join("app/content/legal/terms.en.md")
|
|
214
|
-
|
|
215
|
-
Clickwrap.document :terms,
|
|
216
|
-
version: "2026-08-15",
|
|
217
|
-
locale: :es,
|
|
218
|
-
effective_at: Time.utc(2026, 8, 15),
|
|
219
|
-
from: Rails.root.join("app/content/legal/terms.es.md")
|
|
148
|
+
# Gemfile
|
|
149
|
+
gem "clickwrap", github: "rameerez/clickwrap"
|
|
220
150
|
```
|
|
221
151
|
|
|
222
|
-
Publish them during development or deployment:
|
|
223
|
-
|
|
224
152
|
```bash
|
|
225
|
-
|
|
153
|
+
bundle install
|
|
154
|
+
bin/rails generate clickwrap:install
|
|
155
|
+
bin/rails db:migrate
|
|
226
156
|
```
|
|
227
157
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
- reads the exact bytes;
|
|
231
|
-
- records media type and locale;
|
|
232
|
-
- calculates a versioned digest;
|
|
233
|
-
- snapshots the exact rendered representation when a source format is transformed for display;
|
|
234
|
-
- records the renderer and sanitizer identity/version used for that representation;
|
|
235
|
-
- freezes a database snapshot;
|
|
236
|
-
- compiles and freezes every policy revision that references it; and
|
|
237
|
-
- refuses to reuse a version label for different bytes.
|
|
158
|
+
`bundle add clickwrap` would install version 0.0.0, a deliberately empty name placeholder on RubyGems — install from GitHub until the first real version is published there.
|
|
238
159
|
|
|
239
|
-
The
|
|
240
|
-
|
|
241
|
-
Preview the plan without writing:
|
|
160
|
+
The installer detects Rails authentication vs. Devise, integer vs. UUID primary keys, and your database adapter, then generates adaptive migrations, one annotated initializer, and a conventional signup policy. It emits only the tables your installation can actually write to; the capabilities that are off by default bring their own migration when you want them:
|
|
242
161
|
|
|
243
162
|
```bash
|
|
244
|
-
bin/rails clickwrap:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
```ruby
|
|
250
|
-
config.store_document_contents_in = :active_storage
|
|
163
|
+
bin/rails generate clickwrap:install --with-request-evidence # the IP / user-agent / geolocation annex
|
|
164
|
+
--with-integrity # event chaining, anchoring, timestamps
|
|
165
|
+
--with-retention-ops # legal holds and disposition plans
|
|
166
|
+
--with-external-actions # the outbox for external handoffs
|
|
167
|
+
--with-persisted-presentations
|
|
251
168
|
```
|
|
252
169
|
|
|
253
|
-
|
|
170
|
+
Add any of them later by re-running the generator with the flag. Turning a capability on without its migration is caught at boot, by `bin/rails clickwrap:doctor`, and at the call itself — always with the exact command that fixes it. Enabling a request-evidence field brings the annex table automatically, because an installation that records IP addresses into a table it never created is not a schema choice. If your legal pages already live in the app, it points `from:` at those exact files and writes no `version:` line — the pages name their own versions. It never invents legal text and never silently guesses your actor model.
|
|
254
171
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
## Policies are server-owned offers
|
|
258
|
-
|
|
259
|
-
A policy declares what the server will present and accept. The browser may answer; it may never choose the policy, document version, validity, subject, retention, or request-evidence fields.
|
|
260
|
-
|
|
261
|
-
```ruby
|
|
262
|
-
Clickwrap.policy :driver_declaration do
|
|
263
|
-
declare :non_professional_driver,
|
|
264
|
-
document: :driver_declaration,
|
|
265
|
-
statement: "I declare that I drive privately and not as a professional driver.",
|
|
266
|
-
valid_for: 1.year,
|
|
267
|
-
subject_fingerprint_with: ->(scheme) { scheme.evidence_fingerprint }
|
|
172
|
+
Point the generated policy at the documents your app already owns (see the example above), add `has_clickwraps` to your user model, and drop `form.clickwrap` into your signup form:
|
|
268
173
|
|
|
269
|
-
|
|
270
|
-
|
|
174
|
+
```erb
|
|
175
|
+
<%= form.clickwrap :signup, submit: "Create account" %>
|
|
271
176
|
```
|
|
272
177
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
- missing documents or locales;
|
|
276
|
-
- duplicate statement keys;
|
|
277
|
-
- invalid lifecycle options;
|
|
278
|
-
- a consent policy without a configured withdrawal path;
|
|
279
|
-
- a one-time authorization without expiry/consumption behavior;
|
|
280
|
-
- request evidence without a named present purpose and retention decision;
|
|
281
|
-
- a subject-bound policy without a subject fingerprint; or
|
|
282
|
-
- a changed compiled policy reusing the same revision.
|
|
283
|
-
|
|
284
|
-
Policy revisions are defined pleasantly in Ruby and persisted as frozen canonical snapshots. Historical receipts do not need current source code to explain what revision meant.
|
|
285
|
-
|
|
286
|
-
Every human-facing value can be a literal, an I18n key, or a locale map. Clickwrap resolves it before presentation, fails closed when a required translation is missing, and stores the resolved text and locale—not merely an I18n key whose meaning may change later.
|
|
287
|
-
|
|
288
|
-
### Reacceptance is explicit
|
|
289
|
-
|
|
290
|
-
New document bytes do not silently reinterpret old evidence:
|
|
178
|
+
Then wire the door that creates the account, because the form is only half the circuit — some line has to write the account and its evidence in the same transaction:
|
|
291
179
|
|
|
292
180
|
```ruby
|
|
293
|
-
|
|
294
|
-
|
|
181
|
+
# Devise — app/controllers/users/registrations_controller.rb
|
|
182
|
+
class Users::RegistrationsController < Devise::RegistrationsController
|
|
183
|
+
clickwraps_registration_with :signup
|
|
295
184
|
end
|
|
296
185
|
```
|
|
297
186
|
|
|
298
187
|
```ruby
|
|
299
|
-
|
|
300
|
-
|
|
188
|
+
# Rails authentication, an OAuth finish screen, a service object — any door
|
|
189
|
+
# that builds the record itself.
|
|
190
|
+
unless register_with_clickwrap(:signup, user: @user) { @user.save! }
|
|
191
|
+
return render :new, status: :unprocessable_entity
|
|
192
|
+
end
|
|
301
193
|
```
|
|
302
194
|
|
|
303
|
-
|
|
195
|
+
Do not skip that step. Leave it out and everything still *looks* right — the checkbox renders, the person ticks it, the account is created — and there is no evidence at all. It is the one omission this gem cannot warn you about at runtime, because an app with no door simply never calls it.
|
|
304
196
|
|
|
305
|
-
|
|
197
|
+
Finally, publish immutable snapshots of your documents:
|
|
306
198
|
|
|
307
199
|
```bash
|
|
308
|
-
bin/rails clickwrap:
|
|
200
|
+
bin/rails clickwrap:publish
|
|
309
201
|
```
|
|
310
202
|
|
|
311
|
-
|
|
203
|
+
That's the only time you run that by hand: publishing rides `db:prepare`, so a deploy that runs
|
|
204
|
+
it also freezes the snapshots for whatever you declared, before the server takes traffic
|
|
205
|
+
(`config.publish_documents_after_database_preparation = false` if you'd rather own the step).
|
|
312
206
|
|
|
313
|
-
|
|
207
|
+
That's it! Your app now records which exact document versions the server offered, which explicit
|
|
208
|
+
answers it accepted, the bound presentation wording, and when—atomically with account creation.
|
|
209
|
+
Let's see how it works.
|
|
314
210
|
|
|
315
|
-
|
|
211
|
+
### What that one line renders
|
|
316
212
|
|
|
317
|
-
|
|
318
|
-
- document versions, locales, and digests;
|
|
319
|
-
- exact statements, labels, link labels/targets, choices, required state, and CTA text;
|
|
320
|
-
- actor, tenant, and subject bindings;
|
|
321
|
-
- subject fingerprint;
|
|
322
|
-
- template, application, and gem versions;
|
|
323
|
-
- capture channel;
|
|
324
|
-
- issue time, expiry, and one-use nonce; and
|
|
325
|
-
- a canonical manifest digest.
|
|
213
|
+
One line:
|
|
326
214
|
|
|
327
|
-
|
|
215
|
+
> ☐ I agree to the [Terms of Service](#) and I acknowledge the [Privacy Policy](#).
|
|
328
216
|
|
|
329
|
-
|
|
217
|
+
One checkbox, one label, one sentence, with the documents linked *inside* it. The label **is** the
|
|
218
|
+
line, so pressing the words toggles the control and a screen reader announces the sentence and the
|
|
219
|
+
box together. There is no "Required" flag, no "(opens in a new tab)" printed beside every link, and
|
|
220
|
+
no version label under the checkbox. (The `required` attribute is still there as progressive
|
|
221
|
+
enhancement — **the server decides** — the "opens in a new tab" truth is still announced to screen
|
|
222
|
+
readers when the link really does open one, and versions still appear on receipts, where somebody
|
|
223
|
+
is actually reading the record.)
|
|
330
224
|
|
|
331
|
-
|
|
225
|
+
Behind that single control the evidence is unchanged: two statements, two kinds, two document
|
|
226
|
+
versions, two lifecycles. Ticking the box records an *agreement* to the Terms and an
|
|
227
|
+
*acknowledgment* of the Privacy Notice; leaving it empty refuses both. The manifest signs the exact
|
|
228
|
+
composed sentence and which statements the one control answered, so the substitution defense holds
|
|
229
|
+
over the wording a person actually read.
|
|
230
|
+
|
|
231
|
+
Clickwrap composes that line only when every statement in the policy is an ordinary, required,
|
|
232
|
+
default-worded `agree_to` or `acknowledge`. Anything else keeps a control of its own, **below** the
|
|
233
|
+
line:
|
|
332
234
|
|
|
333
235
|
```ruby
|
|
334
|
-
Clickwrap.policy :
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
end
|
|
339
|
-
```
|
|
236
|
+
Clickwrap.policy :signup do
|
|
237
|
+
# These two compose into the line.
|
|
238
|
+
agree_to :terms, link_label: "Terms of Service"
|
|
239
|
+
acknowledge :privacy_notice, link_label: "Privacy Policy"
|
|
340
240
|
|
|
341
|
-
|
|
241
|
+
# This one gets its own box, below the line, with its withdrawal route.
|
|
242
|
+
consent_to :product_updates,
|
|
243
|
+
document: :marketing_notice,
|
|
244
|
+
optional: true,
|
|
245
|
+
withdrawal_path: "/settings/privacy"
|
|
342
246
|
|
|
343
|
-
|
|
247
|
+
retain_with :ordinary_agreement_evidence
|
|
248
|
+
end
|
|
249
|
+
```
|
|
344
250
|
|
|
345
|
-
|
|
251
|
+
An optional consent is never folded in — bundling it would silently make it required, and unbundled
|
|
252
|
+
consent is the whole point of the `consent_to` verb. Neither is a recorded yes/no, a statement with
|
|
253
|
+
a withdrawal route, or copy your application wrote itself. And a policy with nothing composable —
|
|
254
|
+
the operator attestation rails, the payout authorization — renders exactly as it always has, one
|
|
255
|
+
control per act.
|
|
346
256
|
|
|
347
|
-
|
|
257
|
+
Want the itemized shape anyway? One boolean, and it reaches the presenter, so the manifest signs
|
|
258
|
+
the shape that was actually offered:
|
|
348
259
|
|
|
349
260
|
```erb
|
|
350
|
-
<%= form.clickwrap :signup, submit: "Create account" %>
|
|
261
|
+
<%= form.clickwrap :signup, submit: "Create account", combined: false %>
|
|
351
262
|
```
|
|
352
263
|
|
|
353
|
-
|
|
264
|
+
The words are yours. `clickwrap.sentence.agreement` and `clickwrap.sentence.acknowledgment` are
|
|
265
|
+
ordinary translations with `%{documents}` marking where the links go, and each document's link text
|
|
266
|
+
comes from `link_label:` on the statement — which is how "Privacy Notice" becomes "Privacy Policy"
|
|
267
|
+
without touching what the statement asserts.
|
|
354
268
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
submit: {
|
|
361
|
-
text: "Create organization",
|
|
362
|
-
class: "button button--primary",
|
|
363
|
-
data: { turbo_submits_with: "Creating…" }
|
|
364
|
-
} %>
|
|
365
|
-
```
|
|
269
|
+
Legal pages in Markdown? `config.document_renderer = :markdown` renders through whichever
|
|
270
|
+
Markdown library you already bundle, and `:markdown_rails` renders through your application's
|
|
271
|
+
*own* registered markdown-rails renderer — the exact pipeline your public `/legal` pages go
|
|
272
|
+
through, so the snapshot people accept comes out byte-for-byte identical to the rendered text
|
|
273
|
+
those pages serve, by construction rather than by careful copying.
|
|
366
274
|
|
|
367
|
-
|
|
275
|
+
Wiring the gem into an existing production app — or handing the job to an AI agent? The
|
|
276
|
+
[integrating guide](guides/integrating.md) is the step-by-step playbook from a full
|
|
277
|
+
production migration, in the exact order that avoids every mistake we made.
|
|
368
278
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
- obvious document links before the submit action;
|
|
372
|
-
- stable label/control/error associations;
|
|
373
|
-
- server errors and accessible error summaries;
|
|
374
|
-
- the signed presentation token; and
|
|
375
|
-
- no hidden IP address, browser user-agent, policy version, validity date, or other client-owned security decision.
|
|
279
|
+
Hotwire Native? One setting answers both halves of the native question — the href
|
|
280
|
+
*and* the link attributes:
|
|
376
281
|
|
|
377
|
-
|
|
282
|
+
```ruby
|
|
283
|
+
Clickwrap.configure do |config|
|
|
284
|
+
config.hotwire_native_document_links = {
|
|
285
|
+
open_in: :external_browser,
|
|
286
|
+
canonical_host: "https://www.example.com"
|
|
287
|
+
}
|
|
288
|
+
end
|
|
289
|
+
```
|
|
378
290
|
|
|
379
|
-
|
|
291
|
+
Here's why that matters: on a native authentication sheet, a same-host document
|
|
292
|
+
link is routed by the app itself, which pops the sheet and takes the half-filled
|
|
293
|
+
signup form with it. `:external_browser` absolutizes the signed document path
|
|
294
|
+
against your canonical host and opens it outside the WebView, so the form is
|
|
295
|
+
still there when the person comes back. `:same_screen` keeps a plain same-host
|
|
296
|
+
link for your own native path configuration to route (a document sheet inside a
|
|
297
|
+
signed-in funnel, say).
|
|
380
298
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
submit_button_text: "Create account" %>
|
|
299
|
+
One app often needs both — the auth sheet must escape, the signed-in funnel
|
|
300
|
+
routes its own sheet — so `open_in:` also takes a callable:
|
|
384
301
|
|
|
385
|
-
|
|
302
|
+
```ruby
|
|
303
|
+
config.hotwire_native_document_links = {
|
|
304
|
+
open_in: ->(controller) { controller.signing_up? ? :external_browser : :same_screen },
|
|
305
|
+
canonical_host: "https://www.example.com"
|
|
306
|
+
}
|
|
386
307
|
```
|
|
387
308
|
|
|
388
|
-
|
|
309
|
+
It is asked once when the href is signed and once when the link is rendered,
|
|
310
|
+
with the same controller both times, so the two halves of a link cannot
|
|
311
|
+
disagree.
|
|
389
312
|
|
|
390
|
-
|
|
313
|
+
Another client needs different attributes, or different ones per screen? Keep the
|
|
314
|
+
gem's canonical partial and set `config.document_link_html_options_with`. It can
|
|
315
|
+
add `data: { turbo: false }`, `target`, or `rel`; it cannot replace the immutable
|
|
316
|
+
`href` that Clickwrap signs into the presentation. When the native setting above
|
|
317
|
+
is set it answers native renders entirely, and this hook goes on answering every
|
|
318
|
+
other render.
|
|
391
319
|
|
|
392
|
-
|
|
320
|
+
## How it works
|
|
393
321
|
|
|
394
|
-
|
|
395
|
-
# config/routes.rb
|
|
396
|
-
mount Clickwrap::Engine => "/agreements"
|
|
397
|
-
```
|
|
322
|
+
Most apps eventually accumulate an `accepted_terms_at` column, a `terms_version` string, a few hidden form fields, an `after_create` callback, and some IP columns. Each part looks reasonable alone. Together they produce partial writes, client-owned policy decisions, mutable history, and evidence only the original engineer can explain.
|
|
398
323
|
|
|
399
|
-
|
|
400
|
-
clickwrap_capture_path(:driver_declaration)
|
|
401
|
-
```
|
|
324
|
+
`clickwrap` replaces that plumbing with one coherent primitive:
|
|
402
325
|
|
|
403
|
-
|
|
326
|
+
1. **Documents are immutable.** Publishing reads the exact bytes, digests them, and freezes a snapshot. A changed document requires a new version — the task refuses to reuse a version label for different bytes.
|
|
327
|
+
2. **Policies are server-owned.** The browser may answer; it may never choose the policy, document version, validity, subject, or what gets recorded. Policies compile at boot and fail loudly when misconfigured.
|
|
328
|
+
3. **Presentations are signed.** `form.clickwrap` creates a short-lived signed manifest of what the server generated for the form: documents, digests, statements, choices, and the submit button text. Stale, swapped, expired, or cross-account tokens are rejected at submit. A deploy between render and submit cannot record a version that was not bound to the accepted submission. This does not prove human perception or comprehension.
|
|
329
|
+
4. **Capture is atomic.** Evidence and the protected database action commit together or not at all. Replays of the same submission return the original result instead of running twice.
|
|
330
|
+
5. **Lifecycle history appends.** Through Clickwrap's public/model APIs, withdrawal, expiry, correction, and supersession append new events instead of rewriting the earlier event. Optional PostgreSQL hardening rejects additional direct database mutation paths; the integrity verifier detects covered changes rather than pretending a fully privileged database actor is impossible.
|
|
331
|
+
6. **Receipts have a standalone verifier.** Canonical JSON ([RFC 8785](https://www.rfc-editor.org/rfc/rfc8785)) with versioned schemas and SHA-256 digests can be checked by the bundled `clickwrap` CLI without booting Rails. The result distinguishes fully verified, failed, and incomplete checks; document-byte checks need the exported artifacts, and reviewed disposition is reported as disposition rather than ordinary verification.
|
|
404
332
|
|
|
405
|
-
|
|
333
|
+
## Six verbs, six honest meanings
|
|
406
334
|
|
|
407
|
-
|
|
335
|
+
Not every checkbox is "consent," and not every timestamp is a "signature." Each verb gets the lifecycle it actually needs:
|
|
408
336
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
337
|
+
| Policy verb | Meaning | Typical lifecycle |
|
|
338
|
+
|---|---|---|
|
|
339
|
+
| `agree_to` | Assent to contractual terms | agreed → superseded by new version |
|
|
340
|
+
| `acknowledge` | Affirmative receipt of a notice or risk | acknowledged → superseded / expired |
|
|
341
|
+
| `consent_to` | Purpose-specific permission | granted → withdrawn / renewed |
|
|
342
|
+
| `declare` | A factual statement made by the actor | declared → corrected / expired |
|
|
343
|
+
| `attest` | An operational fact affirmed by an operator | attested → corrected / superseded |
|
|
344
|
+
| `authorize` | Narrow permission bound to one protected action | authorized → consumed / expired |
|
|
414
345
|
|
|
415
|
-
|
|
346
|
+
The DSL is intentionally verbal:
|
|
416
347
|
|
|
417
348
|
```ruby
|
|
418
|
-
|
|
419
|
-
:
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
349
|
+
Clickwrap.policy :example do
|
|
350
|
+
agree_to :terms
|
|
351
|
+
acknowledge :privacy_notice
|
|
352
|
+
consent_to :product_updates, optional: true, withdrawal_path: "/settings/privacy"
|
|
353
|
+
declare :information_is_accurate
|
|
354
|
+
attest :bank_transfer_was_accepted
|
|
355
|
+
authorize :withdrawal, one_time: true, valid_for: 10.minutes
|
|
356
|
+
|
|
357
|
+
retain_with :ordinary_agreement_evidence
|
|
358
|
+
end
|
|
425
359
|
```
|
|
426
360
|
|
|
427
|
-
|
|
428
|
-
<%= hidden_field_tag "clickwrap_submission[presentation_token]", presentation.token %>
|
|
361
|
+
The policy compiler rejects incoherent combinations at boot, in full sentences that tell you what's wrong and what to do about it: a one-time authorization can't be indefinite, consent needs a withdrawal path, and withdrawing future consent never rewrites a historical agreement.
|
|
429
362
|
|
|
430
|
-
|
|
431
|
-
<%# Render statement.control_name, label, document links, choices and errors. %>
|
|
432
|
-
<% end %>
|
|
433
|
-
```
|
|
363
|
+
## Protect an action with its evidence
|
|
434
364
|
|
|
435
|
-
|
|
365
|
+
`capture_and!` is the gem's signature move. In one supported database transaction it verifies the presentation, appends the evidence event, yields to your domain action, records the outcome, and commits both together:
|
|
436
366
|
|
|
437
|
-
|
|
367
|
+
Declare the exact post-action snapshot once. The callback receives the value
|
|
368
|
+
returned by the protected-action block—not the pre-action subject—and
|
|
369
|
+
`Clickwrap.protected_outcome` owns the stable reference and canonical
|
|
370
|
+
fingerprint:
|
|
438
371
|
|
|
439
|
-
|
|
372
|
+
```ruby
|
|
373
|
+
Clickwrap.policy :withdrawal_authorization do
|
|
374
|
+
authorize :withdrawal,
|
|
375
|
+
one_time: true,
|
|
376
|
+
valid_for: 10.minutes,
|
|
377
|
+
protected_outcome_version: "submitted-withdrawal-v1",
|
|
378
|
+
record_protected_outcome_with: lambda { |withdrawal|
|
|
379
|
+
Clickwrap.protected_outcome(
|
|
380
|
+
action: :submitted,
|
|
381
|
+
record: withdrawal,
|
|
382
|
+
state: withdrawal.status,
|
|
383
|
+
facts: {
|
|
384
|
+
amount_in_cents: withdrawal.amount_cents,
|
|
385
|
+
currency: withdrawal.currency,
|
|
386
|
+
destination_reference: withdrawal.destination_reference
|
|
387
|
+
}
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
retain_with :regulated_evidence
|
|
392
|
+
end
|
|
393
|
+
```
|
|
440
394
|
|
|
441
395
|
```ruby
|
|
442
396
|
def create
|
|
443
397
|
withdrawal = current_user.withdrawals.build(withdrawal_params)
|
|
444
398
|
|
|
445
|
-
|
|
446
|
-
:withdrawal_authorization,
|
|
447
|
-
actor: current_user,
|
|
448
|
-
subject: withdrawal
|
|
449
|
-
) do |pending_receipt|
|
|
399
|
+
capture_clickwrap_and!(:withdrawal_authorization, actor: current_user, subject: withdrawal) do |pending_receipt|
|
|
450
400
|
withdrawal.submit!(authorized_by_clickwrap_event: pending_receipt.event_id)
|
|
401
|
+
withdrawal # the exact completed result given to the outcome recorder
|
|
451
402
|
end
|
|
452
403
|
|
|
453
404
|
redirect_to withdrawal
|
|
454
405
|
end
|
|
455
406
|
```
|
|
456
407
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
```ruby
|
|
460
|
-
receipt = Clickwrap.capture_and!(
|
|
461
|
-
:withdrawal_authorization,
|
|
462
|
-
actor: current_user,
|
|
463
|
-
subject: withdrawal,
|
|
464
|
-
http_request: request,
|
|
465
|
-
submission: clickwrap_submission
|
|
466
|
-
) do |pending_receipt|
|
|
467
|
-
withdrawal.submit!(authorized_by_clickwrap_event: pending_receipt.event_id)
|
|
468
|
-
end
|
|
469
|
-
```
|
|
408
|
+
If the event write fails, the action rolls back. If your block raises, the event rolls back. Repeating an identical submission returns the original result without running the block twice; a conflicting replay fails with a stable `Clickwrap::ReplayRejected`. That remains true when the successful action itself changes the fingerprinted subject: once the signed nonce committed, replay verifies the frozen event context and exact answers instead of requiring the old pre-action state to still exist.
|
|
470
409
|
|
|
471
|
-
|
|
410
|
+
Link the row to the evidence that authorized it, so the connection survives years and engineers:
|
|
472
411
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
4. yields its receipt to the protected domain action;
|
|
477
|
-
5. records the resulting outcome and consumes one-time authorization where applicable;
|
|
478
|
-
6. commits both together; and
|
|
479
|
-
7. invokes optional notifications/analytics only after commit.
|
|
480
|
-
|
|
481
|
-
If the event write fails, the protected action rolls back. If the block raises, the event rolls back. Repeating an identical idempotency key returns the original result without running the block twice. A conflicting replay fails with a stable `Clickwrap::ReplayRejected` result.
|
|
412
|
+
```bash
|
|
413
|
+
bin/rails generate clickwrap:link withdrawals && bin/rails db:migrate
|
|
414
|
+
```
|
|
482
415
|
|
|
483
|
-
|
|
416
|
+
```ruby
|
|
417
|
+
class Withdrawal < ApplicationRecord
|
|
418
|
+
has_clickwrap_evidence policy: :withdrawal_authorization,
|
|
419
|
+
statement: :withdrawal,
|
|
420
|
+
actor: :user,
|
|
421
|
+
subject: :self
|
|
422
|
+
end
|
|
484
423
|
|
|
485
|
-
|
|
424
|
+
capture_clickwrap_and!(:withdrawal_authorization, actor: current_user, subject: withdrawal) do |pending_receipt|
|
|
425
|
+
withdrawal.clickwrap_event_id = pending_receipt.event_id
|
|
426
|
+
withdrawal.save!
|
|
427
|
+
withdrawal
|
|
428
|
+
end
|
|
486
429
|
|
|
487
|
-
|
|
430
|
+
withdrawal.clickwrap_receipt.verify.success? # one line, years later
|
|
431
|
+
```
|
|
488
432
|
|
|
489
|
-
|
|
433
|
+
When the protected domain row needs the person's submitted choice, read it
|
|
434
|
+
from the pending receipt rather than parsing controller params a second time:
|
|
490
435
|
|
|
491
436
|
```ruby
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
)
|
|
437
|
+
capture_clickwrap_and!(:privacy_preferences, subject: membership) do |pending_receipt|
|
|
438
|
+
membership.show_on_public_profile =
|
|
439
|
+
pending_receipt.granted?(:public_profile_visibility)
|
|
440
|
+
membership.save!
|
|
441
|
+
end
|
|
498
442
|
```
|
|
499
443
|
|
|
500
|
-
|
|
444
|
+
`answer_for`, `answered?`, `granted?`, and `declined?` read the validated,
|
|
445
|
+
server-bound event being committed. An optional control left unselected returns
|
|
446
|
+
`nil`/`false`; a statement name the policy never declared raises. Silence can
|
|
447
|
+
therefore never become permission, while a typo cannot silently disable a
|
|
448
|
+
feature. This keeps the browser's raw params out of protected domain logic.
|
|
501
449
|
|
|
502
|
-
|
|
450
|
+
This model-first deployment order is safe. Before the generated column exists,
|
|
451
|
+
`has_clickwrap_evidence` stays inert and `clickwrap_receipt` returns `nil`; as
|
|
452
|
+
soon as the migration adds `clickwrap_event_id`, every new row is fail-closed
|
|
453
|
+
by default. That also lets historical data migrations replay schemas from
|
|
454
|
+
before Clickwrap without loading a model method for a column that did not yet
|
|
455
|
+
exist. It does not weaken current rows: after the column exists, missing,
|
|
456
|
+
mismatched, or replaced links fail validation.
|
|
503
457
|
|
|
504
|
-
|
|
458
|
+
And when a *person* causes the refusal — a stale token, a required box left unticked — every such case is one exception family carrying a sentence you can actually show them:
|
|
505
459
|
|
|
506
460
|
```ruby
|
|
507
|
-
|
|
508
|
-
|
|
461
|
+
def create
|
|
462
|
+
# ... capture_clickwrap_and! as above ...
|
|
463
|
+
rescue Clickwrap::CaptureRefused => refusal
|
|
464
|
+
redirect_to new_withdrawal_path, alert: refusal.user_facing_message, status: :see_other
|
|
509
465
|
end
|
|
510
466
|
```
|
|
511
467
|
|
|
512
|
-
|
|
468
|
+
Or drop the bang and let it read like `save`. `capture_clickwrap_and` and
|
|
469
|
+
`capture_clickwrap` absorb exactly that family, return `false`, put the
|
|
470
|
+
per-statement message beside the control it belongs to, and leave the whole
|
|
471
|
+
refusal on `clickwrap_refusal`:
|
|
513
472
|
|
|
514
473
|
```ruby
|
|
515
|
-
|
|
516
|
-
|
|
474
|
+
def create
|
|
475
|
+
receipt = capture_clickwrap_and(:withdrawal_authorization, subject: withdrawal) do |pending_receipt|
|
|
476
|
+
withdrawal.submit!(authorized_by_clickwrap_event: pending_receipt.event_id)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
unless receipt
|
|
480
|
+
flash.now[:alert] = clickwrap_refusal.user_facing_message
|
|
481
|
+
return render :new, status: :unprocessable_entity
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
redirect_to withdrawal
|
|
517
485
|
end
|
|
518
486
|
```
|
|
519
487
|
|
|
520
|
-
|
|
488
|
+
Nothing else is absorbed, by either form. Infrastructure failures stay outside that family and stay loud: an evidence write that fails refuses the protected action instead of being swallowed. So do lifecycle conflicts — a conflicting replay (`Clickwrap::ReplayRejected`) or an already-consumed one-time authorization (`Clickwrap::OneTimeAuthorizationConflict`) still raises, because "this was already done" needs a domain answer that no generic rescue can supply honestly.
|
|
521
489
|
|
|
522
|
-
|
|
490
|
+
That atomicity has one exact boundary: Clickwrap's event and the protected domain
|
|
491
|
+
write must use the same database connection. If a host model uses another Rails
|
|
492
|
+
database/connection, its transaction cannot commit atomically with Clickwrap's
|
|
493
|
+
tables. Put Clickwrap on the same connection for database-local work; use an
|
|
494
|
+
explicit outbox/reconciliation design for another database or service.
|
|
523
495
|
|
|
524
|
-
|
|
525
|
-
2. the presentation token binds to that flow, the form object type, and any host-selected tenant—not to a fictional persisted or authenticated user;
|
|
526
|
-
3. the adapter validates the submitted presentation before account activation;
|
|
527
|
-
4. one transaction persists the account, binds its stable actor reference to the evidence, and commits both; and
|
|
528
|
-
5. the receipt records `account_registration` attribution and the actual pre-registration authentication state.
|
|
496
|
+
For capture without a protected action, use `Clickwrap.capture!`. For external providers (Stripe, identity services) that can't share your database transaction, use `Clickwrap.authorize_external_action!` — a pending authorization plus idempotent outbox, so a provider timeout never becomes a fictional success or a double debit.
|
|
529
497
|
|
|
530
|
-
|
|
498
|
+
Controllers get the ambient actor, tenant, request, authentication context, and
|
|
499
|
+
submitted presentation automatically:
|
|
531
500
|
|
|
532
501
|
```ruby
|
|
533
|
-
|
|
534
|
-
:
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
) do
|
|
539
|
-
@user.save!
|
|
540
|
-
end
|
|
502
|
+
authorization = authorize_clickwrap_external_action!(
|
|
503
|
+
:identity_provider_handoff,
|
|
504
|
+
subject: verification,
|
|
505
|
+
provider_name: "identity_provider"
|
|
506
|
+
)
|
|
541
507
|
```
|
|
542
508
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
Stripe, identity services, timestamp providers, and remote signatures cannot share your database transaction. Use a pending authorization and idempotent outbox:
|
|
509
|
+
If a migration requires a legacy/domain projection to commit with the event and
|
|
510
|
+
pending outbox row, use the deliberately named local-transaction callback (or
|
|
511
|
+
the equivalent block form):
|
|
548
512
|
|
|
549
513
|
```ruby
|
|
550
|
-
authorization =
|
|
514
|
+
authorization = authorize_clickwrap_external_action!(
|
|
551
515
|
:identity_provider_handoff,
|
|
552
|
-
actor: current_user,
|
|
553
516
|
subject: verification,
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
517
|
+
provider_name: "identity_provider",
|
|
518
|
+
after_pending_action_is_saved_inside_transaction: lambda do |pending_action:, pending_receipt:|
|
|
519
|
+
LegacyAuditLog.create!(
|
|
520
|
+
event_id: pending_receipt.event_id,
|
|
521
|
+
external_action_id: pending_action.id
|
|
522
|
+
)
|
|
523
|
+
end
|
|
561
524
|
)
|
|
562
525
|
```
|
|
563
526
|
|
|
527
|
+
It runs once, only on initial capture, inside that local database transaction;
|
|
528
|
+
if it raises, all three local writes roll back. Never call the provider or do
|
|
529
|
+
network work there. The provider call starts only after the helper returns.
|
|
530
|
+
|
|
531
|
+
### One-time, subject-bound authorizations
|
|
532
|
+
|
|
564
533
|
```ruby
|
|
565
|
-
|
|
534
|
+
Clickwrap.policy :withdrawal_authorization do
|
|
535
|
+
acknowledge :withdrawal_requirements
|
|
536
|
+
|
|
537
|
+
declare :coverage_exclusivity,
|
|
538
|
+
subject_fingerprint_version: "covered-orders-v1",
|
|
539
|
+
subject_fingerprint_with: ->(withdrawal) { withdrawal.covered_orders_fingerprint }
|
|
540
|
+
|
|
541
|
+
authorize :withdrawal,
|
|
542
|
+
one_time: true,
|
|
543
|
+
valid_for: 10.minutes,
|
|
544
|
+
requires: %i[withdrawal_requirements coverage_exclusivity]
|
|
545
|
+
|
|
546
|
+
retain_with :regulated_evidence
|
|
547
|
+
end
|
|
566
548
|
```
|
|
567
549
|
|
|
568
|
-
|
|
550
|
+
The authorization is locked and consumed in the same transaction as the withdrawal. Another withdrawal, a changed subject, a stale declaration, or a concurrent replay cannot reuse it. This is the difference between "the user once accepted something" and "this exact evidence authorized this exact operation."
|
|
569
551
|
|
|
570
552
|
## Ask readable questions everywhere
|
|
571
553
|
|
|
@@ -573,407 +555,202 @@ The actor proxy is the everyday API:
|
|
|
573
555
|
|
|
574
556
|
```ruby
|
|
575
557
|
user.clickwraps.current_for?(:signup)
|
|
576
|
-
user.clickwraps.required_for?(:current_terms)
|
|
577
558
|
user.clickwraps.agreed_to?(:terms)
|
|
578
|
-
user.clickwraps.acknowledged?(:privacy_notice)
|
|
579
559
|
user.clickwraps.consented_to?(:product_updates)
|
|
580
|
-
user.clickwraps.declared?(:
|
|
560
|
+
user.clickwraps.declared?(:independent_contractor, subject: scheme)
|
|
581
561
|
user.clickwraps.authorized?(:withdrawal, subject: withdrawal)
|
|
582
562
|
```
|
|
583
563
|
|
|
584
|
-
|
|
564
|
+
When "no" needs an explanation, `verify` returns a structured result with a stable error symbol (`:declaration_expired`, `:consent_withdrawn`, `:wrong_subject`, …), a matching predicate, and a localized message — you never parse English to make an authorization decision. `Clickwrap.require!` raises a typed error carrying the same result. Service boundaries read aloud:
|
|
585
565
|
|
|
586
566
|
```ruby
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
)
|
|
567
|
+
preparation = Clickwrap.verify(:withdrawal_preparation, actor: user,
|
|
568
|
+
require_current_revision: true)
|
|
569
|
+
declaration = Clickwrap.verify(:coverage_exclusivity, actor: user, subject: user,
|
|
570
|
+
require_current_revision: true)
|
|
592
571
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
result.event_id
|
|
597
|
-
result.details # stable machine-readable facts, no surprise PII
|
|
572
|
+
declaration.stale_policy_revision? # legal reworded it → re-ask
|
|
573
|
+
declaration.subject_fingerprint_mismatch? # what it covers changed since capture
|
|
574
|
+
declaration.recorded_after?(preparation) # ordering enforced, not assumed
|
|
598
575
|
```
|
|
599
576
|
|
|
600
|
-
|
|
577
|
+
`recorded_after?` answers from a database-assigned recording sequence, so it stays true across actors, application processes, and same-microsecond writes — ULID lexical order is deliberately not used as chronology. Read its `false` carefully: it means "not after", **or** that one of the two has no sequence at all, which is the case for evidence recorded before the ordering migration and for a missing event. An upgrade cannot invent honest order for rows written before it, so `false` is the answer it gives rather than a guess. Branch on it as a guard (`return unless declaration.recorded_after?(preparation)`), never as proof of the opposite.
|
|
601
578
|
|
|
602
|
-
|
|
579
|
+
`require_current_revision: true` fails evidence recorded under a superseded policy revision, so "we changed the wording, everyone re-accepts" is one keyword instead of a hand-rolled revision comparison.
|
|
603
580
|
|
|
604
|
-
|
|
581
|
+
The same call takes an event id, which is how you re-ask about one specific recorded act years later:
|
|
605
582
|
|
|
606
583
|
```ruby
|
|
607
|
-
|
|
608
|
-
requires_clickwrap :current_terms, only: :show
|
|
609
|
-
end
|
|
584
|
+
Clickwrap.verify(event_id, subject: order_batch, require_current_revision: true)
|
|
610
585
|
```
|
|
611
586
|
|
|
612
|
-
|
|
587
|
+
Both keywords mean exactly what they mean above: `subject:` re-derives the fingerprint from the record as it is *now*, and `require_current_revision:` compares the act's recorded revision against the wording compiled today. That is the complete "is this old evidence still good?" question, so nothing needs to reach into `Clickwrap::PolicyRevision` or `Clickwrap::SubjectFingerprint` to ask it. If the policy is no longer declared at all, the result says `:unknown_policy` — "we can no longer check this" never gets spelled the same way as "this is fine".
|
|
613
588
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
Security-sensitive services should still verify at the domain boundary:
|
|
589
|
+
Controller gates redirect users to a ready-made remediation screen and bring them back when they're done:
|
|
617
590
|
|
|
618
591
|
```ruby
|
|
619
|
-
|
|
620
|
-
:
|
|
621
|
-
|
|
622
|
-
subject: withdrawal
|
|
623
|
-
)
|
|
592
|
+
class BillingController < ApplicationController
|
|
593
|
+
requires_clickwrap :current_terms, only: :show
|
|
594
|
+
end
|
|
624
595
|
```
|
|
625
596
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
## Consent that can actually be withdrawn
|
|
629
|
-
|
|
630
|
-
Consent is purpose-specific, initially unselected, and separate from Terms or a Privacy Notice acknowledgment:
|
|
597
|
+
### Reacceptance when documents change
|
|
631
598
|
|
|
632
599
|
```ruby
|
|
633
|
-
Clickwrap.
|
|
634
|
-
|
|
635
|
-
from: Rails.root.join("app/content/legal/marketing.md")
|
|
636
|
-
|
|
637
|
-
Clickwrap.policy :marketing_preferences do
|
|
638
|
-
consent_to :product_updates,
|
|
639
|
-
document: :marketing_notice,
|
|
640
|
-
optional: true,
|
|
641
|
-
withdrawal_path: "/settings/privacy"
|
|
642
|
-
|
|
643
|
-
consent_to :partner_offers,
|
|
644
|
-
document: :marketing_notice,
|
|
645
|
-
optional: true,
|
|
646
|
-
withdrawal_path: "/settings/privacy"
|
|
600
|
+
Clickwrap.policy :current_terms do
|
|
601
|
+
agree_to :terms, require_current_version: true
|
|
647
602
|
|
|
648
|
-
retain_with :
|
|
603
|
+
retain_with :ordinary_agreement_evidence
|
|
649
604
|
end
|
|
650
605
|
```
|
|
651
606
|
|
|
652
|
-
|
|
607
|
+
Publish a new version and `current_for?` flips to `false` for everyone who accepted the old one. Preview the blast radius before you activate it:
|
|
653
608
|
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
choices: { yes: :grant, no: :decline },
|
|
657
|
-
require_an_explicit_choice: true,
|
|
658
|
-
withdrawal_path: "/settings/privacy"
|
|
609
|
+
```bash
|
|
610
|
+
bin/rails clickwrap:reacceptance:plan POLICY=current_terms
|
|
659
611
|
```
|
|
660
612
|
|
|
661
|
-
|
|
662
|
-
Clickwrap.withdraw!(
|
|
663
|
-
:product_updates,
|
|
664
|
-
actor: current_user,
|
|
665
|
-
http_request: request,
|
|
666
|
-
because: "The user withdrew this purpose in privacy settings"
|
|
667
|
-
)
|
|
668
|
-
```
|
|
613
|
+
## Consent that can actually be withdrawn
|
|
669
614
|
|
|
670
|
-
|
|
615
|
+
Consent is purpose-specific, initially unselected, and separate from Terms:
|
|
671
616
|
|
|
672
617
|
```ruby
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
```
|
|
677
|
-
|
|
678
|
-
Clickwrap structurally requires an accessible withdrawal path. It does not decide whether consent is the correct lawful basis.
|
|
679
|
-
|
|
680
|
-
## Expiring and corrected declarations
|
|
618
|
+
Clickwrap.policy :marketing_preferences do
|
|
619
|
+
consent_to :product_updates, optional: true, withdrawal_path: "/settings/privacy"
|
|
620
|
+
consent_to :partner_offers, optional: true, withdrawal_path: "/settings/privacy"
|
|
681
621
|
|
|
682
|
-
|
|
683
|
-
Clickwrap.policy :driver_declaration do
|
|
684
|
-
declare :non_professional_driver,
|
|
685
|
-
document: :driver_declaration,
|
|
686
|
-
valid_for: 1.year,
|
|
687
|
-
subject_fingerprint_with: ->(scheme) { scheme.evidence_fingerprint }
|
|
622
|
+
retain_with :marketing_consent_evidence
|
|
688
623
|
end
|
|
689
624
|
```
|
|
690
625
|
|
|
691
626
|
```ruby
|
|
692
|
-
|
|
693
|
-
|
|
627
|
+
Clickwrap.withdraw!(:product_updates, actor: current_user, http_request: request,
|
|
628
|
+
because: "The user withdrew this purpose in privacy settings")
|
|
694
629
|
```
|
|
695
630
|
|
|
696
|
-
|
|
631
|
+
Withdrawal appends an event — it never deletes or mutates the historical grant. Declarations work the same way: they expire, get corrected, or get superseded through linked lifecycle events, without pretending the original statement never happened.
|
|
697
632
|
|
|
698
|
-
|
|
699
|
-
Clickwrap.correct_declaration!(
|
|
700
|
-
:non_professional_driver,
|
|
701
|
-
actor: user,
|
|
702
|
-
subject: scheme,
|
|
703
|
-
replaces: old_receipt,
|
|
704
|
-
http_request: request,
|
|
705
|
-
submission: clickwrap_submission
|
|
706
|
-
)
|
|
707
|
-
```
|
|
708
|
-
|
|
709
|
-
The host retains domain-specific eligibility and declaration models. Clickwrap owns presentation, evidence, lifecycle, receipts, and verification—not your business rules.
|
|
710
|
-
|
|
711
|
-
## Narrow, one-time authorizations
|
|
633
|
+
Three of those transitions are new statements by the same person rather than administrative flags, so each one is captured through a real presentation and submission, exactly like the first statement was:
|
|
712
634
|
|
|
713
635
|
```ruby
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
authorize :withdrawal,
|
|
721
|
-
one_time: true,
|
|
722
|
-
valid_for: 10.minutes,
|
|
723
|
-
requires: %i[withdrawal_requirements ride_exclusivity],
|
|
724
|
-
record_protected_outcome_with: lambda { |withdrawal|
|
|
725
|
-
{
|
|
726
|
-
action: :submitted,
|
|
727
|
-
reference: withdrawal.to_gid.to_s,
|
|
728
|
-
fingerprint: withdrawal.evidence_fingerprint
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
end
|
|
732
|
-
```
|
|
733
|
-
|
|
734
|
-
`capture_and!` locks and consumes the authorization in the same transaction as the withdrawal. Another withdrawal, changed ride set, stale declaration, wrong ordering, or concurrent replay cannot reuse it.
|
|
735
|
-
|
|
736
|
-
This is the core difference between “the user once accepted something” and “this exact evidence authorized this exact operation.”
|
|
737
|
-
|
|
738
|
-
## Operator attestations
|
|
739
|
-
|
|
740
|
-
```ruby
|
|
741
|
-
Clickwrap.policy :manual_bank_transfer do
|
|
742
|
-
attest :beneficiary_matches_verified_identity
|
|
743
|
-
attest :bank_accepted_transfer
|
|
744
|
-
authorize :record_transfer_as_sent, one_time: true
|
|
745
|
-
end
|
|
746
|
-
```
|
|
747
|
-
|
|
748
|
-
Attestations preserve which authorized operator asserted which operational fact, under which role and authentication context, while the host owns permissions and domain state.
|
|
749
|
-
|
|
750
|
-
## External agreements and imported receipts
|
|
636
|
+
# The facts someone declared changed. A correction never implies the original
|
|
637
|
+
# was false when it was made.
|
|
638
|
+
Clickwrap.correct_declaration!(:contractor_status, actor: current_user, subject: engagement,
|
|
639
|
+
submission: clickwrap_submission,
|
|
640
|
+
because: "The person told us their circumstances changed")
|
|
751
641
|
|
|
752
|
-
|
|
642
|
+
# A new validity period, starting now — never the old expiry pushed along, so a
|
|
643
|
+
# stale expiry cannot quietly survive a renewal.
|
|
644
|
+
Clickwrap.renew!(:contractor_status, actor: current_user, subject: engagement,
|
|
645
|
+
submission: clickwrap_submission,
|
|
646
|
+
because: "The person renewed their declaration before it lapsed")
|
|
753
647
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
provider_event_id: account.id,
|
|
760
|
-
provider_receipt: account.service_agreement,
|
|
761
|
-
verified_with: :stripe_api,
|
|
762
|
-
verified_at: Time.current
|
|
763
|
-
)
|
|
648
|
+
# Consent that now covers something narrower or wider. Rescoping is not
|
|
649
|
+
# withdrawal: the permission stays active, under new terms.
|
|
650
|
+
Clickwrap.change_consent_scope!(:product_updates, actor: current_user,
|
|
651
|
+
submission: clickwrap_submission,
|
|
652
|
+
because: "The person narrowed this permission in privacy settings")
|
|
764
653
|
```
|
|
765
654
|
|
|
766
|
-
|
|
655
|
+
Every one of them appends a linked event, leaves the earlier event exactly as it was recorded, and produces a receipt that verifies on its own.
|
|
767
656
|
|
|
768
|
-
|
|
657
|
+
Seeds, imports, and admin-created accounts never fake a human click either — `Clickwrap.exempt!` records an explicit exemption with who created it and why, and exemptions never satisfy `agreed_to?`.
|
|
658
|
+
|
|
659
|
+
## Receipts show exactly what the application recorded
|
|
769
660
|
|
|
770
661
|
Every event has one canonical JSON receipt and one human-readable HTML projection:
|
|
771
662
|
|
|
772
663
|
```ruby
|
|
773
664
|
receipt = Clickwrap.receipt(event_id)
|
|
774
|
-
|
|
775
665
|
receipt.to_canonical_json
|
|
776
666
|
receipt.to_html
|
|
777
|
-
receipt.to_pdf # optional renderer; never the source of truth
|
|
778
667
|
receipt.verify
|
|
779
668
|
```
|
|
780
669
|
|
|
781
|
-
An abbreviated receipt
|
|
670
|
+
An abbreviated receipt:
|
|
782
671
|
|
|
783
672
|
```json
|
|
784
673
|
{
|
|
785
674
|
"schema": "clickwrap.receipt.v1",
|
|
786
675
|
"event_id": "01K2Y8T5QY0N4V6N1H4G4CQY8J",
|
|
787
676
|
"policy": { "key": "signup", "revision": "sha256:..." },
|
|
788
|
-
"actor": {
|
|
789
|
-
"type": "User",
|
|
790
|
-
"reference": "usr_...",
|
|
791
|
-
"attribution": { "method": "account_registration", "authenticated": false }
|
|
792
|
-
},
|
|
793
677
|
"acts": [
|
|
794
678
|
{ "statement": "terms", "kind": "agreement", "action": "agreed" },
|
|
795
|
-
{
|
|
796
|
-
"statement": "privacy_notice",
|
|
797
|
-
"kind": "acknowledgment",
|
|
798
|
-
"action": "acknowledged"
|
|
799
|
-
}
|
|
679
|
+
{ "statement": "privacy_notice", "kind": "acknowledgment", "action": "acknowledged" }
|
|
800
680
|
],
|
|
801
681
|
"documents": [
|
|
802
|
-
{ "key": "terms", "version": "2026-08-15", "locale": "en", "sha256": "..." },
|
|
803
682
|
{
|
|
804
|
-
"key": "
|
|
683
|
+
"key": "terms",
|
|
805
684
|
"version": "2026-08-15",
|
|
806
685
|
"locale": "en",
|
|
807
|
-
"
|
|
686
|
+
"source_digest": "sha256:...",
|
|
687
|
+
"rendered_digest": "sha256:..."
|
|
808
688
|
}
|
|
809
689
|
],
|
|
810
690
|
"presentation": {
|
|
811
|
-
"
|
|
691
|
+
"manifest_digest": "sha256:...",
|
|
812
692
|
"submit_button_text": "Create account",
|
|
813
693
|
"offered_at": "2026-08-15T12:34:56.123456Z"
|
|
814
694
|
},
|
|
815
|
-
"
|
|
816
|
-
"request_evidence": {
|
|
817
|
-
"ip_address": { "state": "not_configured" },
|
|
818
|
-
"browser_user_agent": { "state": "not_configured" },
|
|
819
|
-
"ip_geolocation": { "state": "not_configured" }
|
|
820
|
-
},
|
|
821
|
-
"integrity": { "digest_algorithm": "sha256", "verified": true }
|
|
695
|
+
"integrity": { "digest_algorithm": "sha256", "receipt_digest": "sha256:..." }
|
|
822
696
|
}
|
|
823
697
|
```
|
|
824
698
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
`to_canonical_json` returns the verifiable core receipt and omits raw sensitive request evidence by default. Raw IP address, browser user-agent, and IP-geolocation values live in a separately encrypted evidence annex with its own digest, authorization, retention, hold, and disposition state. That boundary lets the core event remain immutable when a permitted retention process later removes the annex.
|
|
828
|
-
|
|
829
|
-
Canonical receipts use versioned schemas and the [JSON Canonicalization Scheme (RFC 8785)](https://www.rfc-editor.org/rfc/rfc8785), plus a published Clickwrap profile for UTC timestamps, decimals, identifiers, binary digests, absent values, and extension names. They never depend on Ruby object serialization, YAML, database column order, or the current policy source. Unknown schema versions fail honestly instead of being “best effort” reinterpreted.
|
|
830
|
-
|
|
831
|
-
### View and download
|
|
832
|
-
|
|
833
|
-
With the engine mounted:
|
|
834
|
-
|
|
835
|
-
```ruby
|
|
836
|
-
clickwrap_receipt_path(receipt)
|
|
837
|
-
```
|
|
838
|
-
|
|
839
|
-
Actors can view their own receipts. Operator access is always host-authorized:
|
|
840
|
-
|
|
841
|
-
```ruby
|
|
842
|
-
config.authorize_receipt_access_with = lambda do |controller, receipt|
|
|
843
|
-
controller.current_user == receipt.actor || controller.current_user.admin?
|
|
844
|
-
end
|
|
845
|
-
```
|
|
846
|
-
|
|
847
|
-
Foreign IDs return not found; existence is not leaked.
|
|
848
|
-
|
|
849
|
-
### Export only the sensitive fields you intend
|
|
850
|
-
|
|
851
|
-
```ruby
|
|
852
|
-
Clickwrap.export_receipt(
|
|
853
|
-
receipt,
|
|
854
|
-
requested_by: current_operator,
|
|
855
|
-
because: "Investigate dispute 2026-184",
|
|
856
|
-
include_ip_address: false,
|
|
857
|
-
include_browser_user_agent: false,
|
|
858
|
-
include_ip_geolocation: false
|
|
859
|
-
)
|
|
860
|
-
```
|
|
861
|
-
|
|
862
|
-
There is intentionally no vague `include_sensitive_context: true` switch. Unredacted operator access and export require host authorization plus a human-readable reason and append an access event. Actor self-service follows the host’s configured disclosure policy without revealing internal fraud/security fields by accident.
|
|
863
|
-
|
|
864
|
-
### Verify inside or outside the application
|
|
865
|
-
|
|
866
|
-
```ruby
|
|
867
|
-
Clickwrap::Receipt.verify(canonical_json, documents: document_files)
|
|
868
|
-
```
|
|
699
|
+
Verify it inside the app, or completely outside it with the bundled CLI:
|
|
869
700
|
|
|
870
701
|
```bash
|
|
871
702
|
clickwrap verify receipt.json --documents ./receipt-documents
|
|
872
703
|
```
|
|
873
704
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
## Optional request evidence, private by default
|
|
705
|
+
Golden fixtures make a verifier regression for any released receipt schema fail the test suite.
|
|
877
706
|
|
|
878
|
-
|
|
707
|
+
With the engine mounted, users can view and download their own receipts, and operator access is always host-authorized. Read the [receipts and verification guide](guides/receipts-and-verification.md) for exports, bundles, and what each verification tier does and doesn't establish.
|
|
879
708
|
|
|
880
|
-
|
|
709
|
+
## Request evidence is off by default
|
|
881
710
|
|
|
882
|
-
-
|
|
883
|
-
- raw browser User-Agent;
|
|
884
|
-
- IP-geolocation country, region, city, postal code, coordinates, timezone, continent, metro code, or accuracy radius;
|
|
885
|
-
- browser/device fingerprints; or
|
|
886
|
-
- actual GPS/device location.
|
|
887
|
-
|
|
888
|
-
Browser fingerprinting and GPS are never collected by the base gem. IP geolocation is provider-estimated network context—not identity, GPS, a street address, or proof that the person was physically there.
|
|
889
|
-
|
|
890
|
-
Those defaults are evidence design, not fear of useful data. IP addresses and linked online identifiers can be personal data ([Breyer, C-582/14](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A62014CJ0582)); keeping them on first-party infrastructure does not remove purpose, lawful-basis, transparency, minimization, protection-by-default, security, retention, or high-risk-assessment duties ([GDPR Articles 5](https://eur-lex.europa.eu/eli/reg/2016/679/art_5/oj/eng), [6](https://eur-lex.europa.eu/eli/reg/2016/679/art_6/oj/eng), [13](https://eur-lex.europa.eu/eli/reg/2016/679/art_13/oj/eng), [25](https://eur-lex.europa.eu/eli/reg/2016/679/art_25/oj/eng), [32](https://eur-lex.europa.eu/eli/reg/2016/679/art_32/oj/eng), and [35](https://eur-lex.europa.eu/eli/reg/2016/679/art_35/oj/eng)). Clickwrap therefore supports rich capture while requiring a present, named posture.
|
|
891
|
-
|
|
892
|
-
MaxMind expressly describes GeoIP as approximate and not capable of identifying a household, individual, or street address; Cloudflare describes its fields as location information for an IP address ([MaxMind accuracy guidance](https://support.maxmind.com/knowledge-base/articles/maxmind-geolocation-accuracy); [Cloudflare IP geolocation](https://developers.cloudflare.com/network/ip-geolocation/)). Clickwrap preserves that uncertainty instead of polishing an estimate into a stronger claim.
|
|
893
|
-
|
|
894
|
-
### Enable exactly what one policy needs
|
|
711
|
+
`clickwrap` always records its event ID, server time, capture channel, and policy version. It records **no** IP addresses, browser user-agents, or IP geolocation unless a policy names the field with a purpose and a retention rule:
|
|
895
712
|
|
|
896
713
|
```ruby
|
|
897
714
|
Clickwrap.policy :regulated_authorization do
|
|
898
715
|
authorize :regulated_action, one_time: true, valid_for: 10.minutes
|
|
899
716
|
|
|
900
|
-
review_request_evidence_configuration_on Date.new(2027, 8, 15)
|
|
901
|
-
|
|
902
717
|
record_ip_address(
|
|
903
718
|
encrypted: true,
|
|
904
719
|
retain_until: :regulated_evidence_retention_ends,
|
|
905
|
-
because: "Investigate account compromise and disputes about this action"
|
|
906
|
-
legal_basis_reference: "LIA-SECURITY-2026-01"
|
|
907
|
-
)
|
|
908
|
-
|
|
909
|
-
record_browser_user_agent(
|
|
910
|
-
encrypted: true,
|
|
911
|
-
retain_until: :regulated_evidence_retention_ends,
|
|
912
|
-
because: "Corroborate the client context used for this action",
|
|
913
|
-
legal_basis_reference: "LIA-SECURITY-2026-01"
|
|
720
|
+
because: "Investigate account compromise and disputes about this action"
|
|
914
721
|
)
|
|
915
722
|
|
|
916
|
-
|
|
917
|
-
country: true,
|
|
918
|
-
region: true,
|
|
919
|
-
city: true,
|
|
920
|
-
postal_code: false,
|
|
921
|
-
latitude_and_longitude: true,
|
|
922
|
-
timezone: true,
|
|
923
|
-
continent: false,
|
|
924
|
-
metro_code: false,
|
|
925
|
-
accuracy_radius_in_kilometers: true,
|
|
926
|
-
using: :trackdown,
|
|
927
|
-
retain_until: :regulated_evidence_retention_ends,
|
|
928
|
-
because: "Corroborate anomalous access and investigate action disputes",
|
|
929
|
-
legal_basis_reference: "LIA-SECURITY-2026-01",
|
|
930
|
-
data_protection_impact_assessment_reference: "DPIA-2026-04"
|
|
931
|
-
)
|
|
723
|
+
retain_with :regulated_evidence
|
|
932
724
|
end
|
|
933
725
|
```
|
|
934
726
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
Required request enrichment resolves before the evidence/domain transaction begins and is carried into it as verified input; it is never filled in later by analytics. A policy chooses explicitly whether an unavailable resolver blocks capture or produces an `unavailable` state. Network resolvers are supported, but local databases or already-verified edge metadata avoid holding a domain transaction open around a remote call.
|
|
942
|
-
|
|
943
|
-
### Trackdown is the optional official resolver
|
|
727
|
+
Recorded values live in a separately encrypted annex with their own retention, so
|
|
728
|
+
they can be deleted later without rewriting the core event payload. Core payloads
|
|
729
|
+
have their own reviewed disposition path and leave a digest-linked tombstone.
|
|
730
|
+
There is deliberately no `gdpr_compliant_mode` or `maximum_evidence` switch —
|
|
731
|
+
every field is named individually, in plain English.
|
|
944
732
|
|
|
945
|
-
|
|
946
|
-
bundle add trackdown
|
|
947
|
-
```
|
|
733
|
+
For IP geolocation, [`trackdown`](https://github.com/rameerez/trackdown) 0.4 or newer is the optional official resolver:
|
|
948
734
|
|
|
949
735
|
```ruby
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
`footprinted` remains analytics, not authoritative evidence. A sanitized event ID/policy/kind may be emitted to analytics after commit; analytics failure can never undo or substitute for the Clickwrap event.
|
|
957
|
-
|
|
958
|
-
### Easy installer recipes without a fake compliance switch
|
|
959
|
-
|
|
960
|
-
The installer can scaffold either starting point:
|
|
961
|
-
|
|
962
|
-
```bash
|
|
963
|
-
bin/rails generate clickwrap:install \
|
|
964
|
-
--request-evidence-recipe=privacy-minimized
|
|
965
|
-
```
|
|
736
|
+
Trackdown.configure do |trackdown|
|
|
737
|
+
trackdown.verify_request_came_through_trusted_cloudflare_path_with do |request|
|
|
738
|
+
request.env["my_app.cloudflare_origin_was_verified"] == true
|
|
739
|
+
end
|
|
740
|
+
end
|
|
966
741
|
|
|
967
|
-
|
|
968
|
-
bin/rails generate clickwrap:install \
|
|
969
|
-
--request-evidence-recipe=evidence-rich
|
|
742
|
+
config.ip_geolocation_resolver = Clickwrap::IpGeolocation::TrackdownResolver.new
|
|
970
743
|
```
|
|
971
744
|
|
|
972
|
-
|
|
745
|
+
Clickwrap passes the exact Rack request to Trackdown and records the provider that actually
|
|
746
|
+
answered, its source and database provenance, and Trackdown's per-request trust result. It
|
|
747
|
+
never treats CDN header presence as proof of a trusted path. The host must derive the Rack
|
|
748
|
+
flag above from its real origin protection; Trackdown documents the supported patterns in
|
|
749
|
+
[“Did the request really come through your CDN?”](https://github.com/rameerez/trackdown/blob/v0.4.0/README.md#did-the-request-really-come-through-your-cdn).
|
|
973
750
|
|
|
974
|
-
|
|
751
|
+
The [request evidence guide](guides/request-evidence.md) covers every field, the provenance model, and the privacy boundaries.
|
|
975
752
|
|
|
976
|
-
## Retention, deletion, and legal holds
|
|
753
|
+
## Retention, deletion, and legal holds
|
|
977
754
|
|
|
978
755
|
Every policy chooses an application-defined retention class:
|
|
979
756
|
|
|
@@ -982,727 +759,576 @@ Clickwrap.retention :ordinary_agreement_evidence do
|
|
|
982
759
|
retain_core_event_for 6.years
|
|
983
760
|
delete_recorded_ip_address_after 90.days
|
|
984
761
|
delete_recorded_browser_user_agent_after 90.days
|
|
985
|
-
delete_recorded_ip_geolocation_after 90.days
|
|
986
762
|
end
|
|
987
763
|
```
|
|
988
764
|
|
|
989
|
-
|
|
765
|
+
Disposition is previewed, planned, and applied explicitly — and rechecked at apply time, so a newly placed legal hold or changed policy stops a stale plan:
|
|
990
766
|
|
|
991
|
-
```
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
retain_recorded_ip_address_until :security_evidence_retention_ends
|
|
995
|
-
retain_recorded_browser_user_agent_until :security_evidence_retention_ends
|
|
996
|
-
retain_recorded_ip_geolocation_until :security_evidence_retention_ends
|
|
997
|
-
end
|
|
767
|
+
```bash
|
|
768
|
+
bin/rails clickwrap:retention:plan
|
|
769
|
+
bin/rails clickwrap:retention:apply PLAN=01K2Y8T5QY0N4V6N1H4G4CQY8J
|
|
998
770
|
```
|
|
999
771
|
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
event.recorded_at_by_server + 5.years,
|
|
1004
|
-
event.subject_liquidated_at&.+(3.years)
|
|
1005
|
-
].compact.max
|
|
1006
|
-
end
|
|
1007
|
-
```
|
|
772
|
+
Destructive methods say exactly what they delete (`Clickwrap.delete_recorded_ip_address!`), deletions append a disposition event, and deleting a user account never silently cascades evidence away. Each event keeps the schedule recorded when that event was created; linked lifecycle events do not inherit their root's elapsed time or get deleted merely because the root became due. Legal holds pause disposition and are recorded through named append/release transitions. Details in the [retention and legal holds guide](guides/retention-and-legal-holds.md).
|
|
773
|
+
|
|
774
|
+
## Progressive integrity, honestly labeled
|
|
1008
775
|
|
|
1009
|
-
|
|
776
|
+
Start useful with an ordinary Rails database; add assurance without changing the capture API:
|
|
1010
777
|
|
|
1011
|
-
|
|
778
|
+
| Tier | What it adds |
|
|
779
|
+
|---|---|
|
|
780
|
+
| Baseline | Canonical receipts, immutable snapshots, SHA-256 digests, standalone verifier |
|
|
781
|
+
| Database hardening | Adapter-specific update/delete protections |
|
|
782
|
+
| Chained history | Per-tenant event chains and checkpoints |
|
|
783
|
+
| Independent anchoring | A verified publication of an exact event-chain snapshot outside the primary database |
|
|
784
|
+
| Third-party timestamps | A provider token over an exact event digest, with the adapter's verification result |
|
|
785
|
+
|
|
786
|
+
Each tier states exactly what threat it addresses. A local hash is never called tamper-proof, server time is never called trusted time, and an IP address is never called identity. The [integrity guide](guides/integrity.md) has the threat model.
|
|
1012
787
|
|
|
1013
788
|
```bash
|
|
1014
|
-
bin/rails clickwrap:
|
|
1015
|
-
bin/rails clickwrap:retention:apply PLAN=01K2Y8T5QY0N4V6N1H4G4CQY8J
|
|
789
|
+
bin/rails clickwrap:verify # verify continuously in production
|
|
1016
790
|
```
|
|
1017
791
|
|
|
1018
|
-
|
|
792
|
+
## Works with Devise, Rails authentication, Hotwire, and APIs
|
|
1019
793
|
|
|
1020
|
-
|
|
794
|
+
The installer detects your authentication stack and prints the exact door line to add, with your own file path and class name filled in. You add it yourself: this is an explicit adapter you can read in your own controller, not a hidden `after_create` callback the gem installs behind your back.
|
|
1021
795
|
|
|
1022
796
|
```ruby
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
797
|
+
# Devise
|
|
798
|
+
class Users::RegistrationsController < Devise::RegistrationsController
|
|
799
|
+
clickwraps_registration_with :signup
|
|
800
|
+
end
|
|
1026
801
|
```
|
|
1027
802
|
|
|
1028
|
-
Deletion removes the selected encrypted annex value, appends a disposition event, and changes the current receipt projection to `deleted`; it does not rewrite the historical agreement/declaration/authorization. Verification thereafter proves the immutable core event and its disposition history while reporting that the raw annex value is no longer available. A retained digest is described as a retained linkable digest, never automatically called anonymous.
|
|
1029
|
-
|
|
1030
|
-
### Legal holds
|
|
1031
|
-
|
|
1032
803
|
```ruby
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
review_on: 6.months.from_now
|
|
1037
|
-
)
|
|
804
|
+
# Rails authentication generator, or any hand-rolled signup door
|
|
805
|
+
def create
|
|
806
|
+
@user = User.new(user_params)
|
|
1038
807
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
808
|
+
unless register_with_clickwrap(:signup, user: @user) { @user.save! }
|
|
809
|
+
return render :new, status: :unprocessable_entity
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
start_new_session_for @user
|
|
813
|
+
redirect_to after_authentication_url
|
|
814
|
+
end
|
|
1043
815
|
```
|
|
1044
816
|
|
|
1045
|
-
|
|
817
|
+
Both make account activation and its evidence commit together, with a prospective-actor flow that's honest about the fact that no authenticated user exists yet at render time.
|
|
1046
818
|
|
|
1047
|
-
|
|
819
|
+
The door helpers come in a pair, exactly like `save` and `save!`. The non-bang form absorbs a *refused* signup — a stale presentation, an unticked box, a validation the account failed — into the same human sentences the Devise adapter paints (inline beside the control, once on the record's `:base`) and returns `false`, ready for that 422 re-render. `register_with_clickwrap!` raises instead, for flows that handle the exceptions themselves. An infrastructure failure escapes *both* forms: a broken database is not a refusal to dress up as validation, so the sign-in, the welcome email, and the redirect that would normally follow simply do not happen. That's the difference between a refused signup and a live account nobody can explain.
|
|
1048
820
|
|
|
1049
|
-
|
|
821
|
+
During a legacy migration, keep a required dual-write inside that same
|
|
822
|
+
transaction without replacing Devise's controller action:
|
|
1050
823
|
|
|
1051
|
-
|
|
824
|
+
```ruby
|
|
825
|
+
class Users::RegistrationsController < Devise::RegistrationsController
|
|
826
|
+
clickwraps_registration_with :signup,
|
|
827
|
+
after_account_is_saved_inside_transaction: :record_legacy_acceptance!
|
|
1052
828
|
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
829
|
+
private
|
|
830
|
+
|
|
831
|
+
def record_legacy_acceptance!(account:, pending_receipt:)
|
|
832
|
+
account.terms_acceptances.create!(
|
|
833
|
+
clickwrap_event_id: pending_receipt.event_id,
|
|
834
|
+
accepted_at: Time.current
|
|
835
|
+
)
|
|
836
|
+
end
|
|
837
|
+
end
|
|
1057
838
|
```
|
|
1058
839
|
|
|
1059
|
-
|
|
840
|
+
If that required legacy write fails, the account and Clickwrap evidence roll
|
|
841
|
+
back with it. Remove the hook after parity and cutover are proved.
|
|
1060
842
|
|
|
1061
|
-
|
|
843
|
+
Want the gem's controls but your own button markup? `form.clickwrap_fields` takes a block and hands you the signed presentation, so the wording is read rather than retyped:
|
|
1062
844
|
|
|
1063
|
-
```
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
actor,
|
|
1068
|
-
requested_by: current_operator,
|
|
1069
|
-
because: "Verified erasure request DSAR-2026-41"
|
|
1070
|
-
)
|
|
845
|
+
```erb
|
|
846
|
+
<%= form.clickwrap_fields :signup, submit_button_text: "Create account" do |clickwrap| %>
|
|
847
|
+
<button type="submit" class="btn btn--primary"><%= clickwrap.submit_button_text %></button>
|
|
848
|
+
<% end %>
|
|
1071
849
|
```
|
|
1072
850
|
|
|
1073
|
-
|
|
851
|
+
`submit:` and `submit_button_text:` are a deliberate pair: `form.clickwrap :signup, submit: "Create account"` binds the words *and renders the button*, while `form.clickwrap_fields :signup, submit_button_text: "Create account"` binds the words and leaves the action to you.
|
|
1074
852
|
|
|
1075
|
-
|
|
853
|
+
Everything is server-rendered HTML: full-page requests, Turbo Drive and Frames, no-JavaScript validation, and Hotwire Native all work with the same helper. JSON/API clients use `Clickwrap.present` to get the server-owned manifest and submit answers with the signed token. Views are ejectable with `bin/rails generate clickwrap:views`, or build fully custom UI on `Clickwrap.present` plus the view helpers — `clickwrap_presentation_token_field`, `clickwrap_statement_check_box`, `clickwrap_statement_radio_button`, and `clickwrap_submit_button` own the envelope name, the control names, and a call to action worded by the signed manifest itself, while you own every class and wrapper around them. The [integrating guide](guides/integrating.md#4-custom-surfaces--the-three-contracts) shows a full custom surface.
|
|
1076
854
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
| Tier | Capability | Honest claim |
|
|
1080
|
-
|---|---|---|
|
|
1081
|
-
| Baseline | Canonical receipts, immutable snapshots, versioned SHA-256 digests, append-only public API, independent verifier | Detects accidental/ordinary mutation of the verified bytes |
|
|
1082
|
-
| Database hardening | Constraints and adapter-specific update/delete protections | Rejects unsupported mutation paths within the documented database threat model |
|
|
1083
|
-
| Chained history | Per-tenant or per-aggregate event chains/checkpoints | Makes rewriting history detectable when checkpoints remain trustworthy |
|
|
1084
|
-
| Independent anchoring | Heads stored/published outside the primary database | Improves evidence against a privileged primary-database rewrite |
|
|
1085
|
-
| Trusted timestamp/provider | RFC 3161 or qualified trust-service receipt adapters | Preserves exactly the assurance and validation status supplied by that provider |
|
|
855
|
+
### Works with `organizations`
|
|
1086
856
|
|
|
1087
|
-
|
|
857
|
+
A human `User` can bind an `Organizations::Organization` without collapsing
|
|
858
|
+
the two identities:
|
|
1088
859
|
|
|
1089
|
-
```
|
|
1090
|
-
|
|
1091
|
-
|
|
860
|
+
```ruby
|
|
861
|
+
Clickwrap.policy :organization_terms do
|
|
862
|
+
agree_to :organization_terms
|
|
863
|
+
permit_acting_for_organization when_actor_is_at_least: :admin
|
|
864
|
+
retain_with :ordinary_agreement_evidence
|
|
865
|
+
end
|
|
1092
866
|
```
|
|
1093
867
|
|
|
1094
|
-
```
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
config.timestamp_receipts_with = MyRfc3161TimestampProvider.new
|
|
868
|
+
```erb
|
|
869
|
+
<%= form.clickwrap :organization_terms,
|
|
870
|
+
acting_for: current_organization,
|
|
871
|
+
submit: "Accept for #{current_organization.name}" %>
|
|
1099
872
|
```
|
|
1100
873
|
|
|
1101
|
-
|
|
874
|
+
Authority is checked when the form is presented and reread from the membership
|
|
875
|
+
inside the capture transaction. The receipt records the human actor,
|
|
876
|
+
represented organization, actual role at both moments, authority criterion,
|
|
877
|
+
source, and verification times separately. Clickwrap records the configured
|
|
878
|
+
application-authorization fact; it does not decide whether that role is legally
|
|
879
|
+
sufficient. The same integration can create a brand-new organization and its
|
|
880
|
+
owner membership atomically through `create_represented_party_with_clickwrap`.
|
|
881
|
+
See [Binding an organization through a human actor](guides/organizations.md).
|
|
1102
882
|
|
|
1103
|
-
|
|
883
|
+
## Recipes
|
|
1104
884
|
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
885
|
+
Two situations come up in almost every real app. Here's exactly how to handle both.
|
|
886
|
+
|
|
887
|
+
### "Accept the new Terms to continue" — wall the app until updated Terms are accepted
|
|
888
|
+
|
|
889
|
+
You know how Apple Developer releases new terms every few months and walls off the entire dashboard until you accept them? Same pattern here: legal ships a new version of your Terms, and nobody uses your app again until they've agreed to it. Accepting the new version supersedes the old one — and you keep a receipt for every version each user ever agreed to, so you always know exactly who agreed to exactly what, and when.
|
|
890
|
+
|
|
891
|
+
Bump the document version when the new text ships — in the file itself, beside
|
|
892
|
+
the words that changed. The top of `app/content/legal/terms.md`:
|
|
893
|
+
|
|
894
|
+
```markdown
|
|
895
|
+
---
|
|
896
|
+
title: Terms of Service
|
|
897
|
+
last_updated: 2026-11-01
|
|
898
|
+
---
|
|
1108
899
|
```
|
|
1109
900
|
|
|
1110
|
-
|
|
901
|
+
It was `2026-08-15`; new words mean a new label. (A trailing `# comment` on
|
|
902
|
+
that line is read as YAML reads it — not part of the label.)
|
|
1111
903
|
|
|
1112
|
-
|
|
904
|
+
And require the current version in the policy:
|
|
1113
905
|
|
|
1114
906
|
```ruby
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
907
|
+
# config/clickwrap.rb
|
|
908
|
+
Clickwrap.policy :current_terms do
|
|
909
|
+
agree_to :terms, require_current_version: true
|
|
1118
910
|
|
|
1119
|
-
|
|
1120
|
-
controller.current_organization
|
|
1121
|
-
end
|
|
911
|
+
retain_with :ordinary_agreement_evidence
|
|
1122
912
|
end
|
|
1123
913
|
```
|
|
1124
914
|
|
|
1125
|
-
|
|
915
|
+
Mount the built-in acceptance screen and wall the app:
|
|
1126
916
|
|
|
1127
917
|
```ruby
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
actor: current_user,
|
|
1131
|
-
subject: @organization,
|
|
1132
|
-
tenant: current_organization,
|
|
1133
|
-
http_request: request,
|
|
1134
|
-
submission: clickwrap_submission
|
|
1135
|
-
)
|
|
918
|
+
# config/routes.rb
|
|
919
|
+
mount Clickwrap::Engine => "/agreements"
|
|
1136
920
|
```
|
|
1137
921
|
|
|
1138
|
-
Actor snapshots include only configured fields. Clickwrap never serializes a whole user or domain object into evidence.
|
|
1139
|
-
|
|
1140
|
-
Authentication, actor, organization, and subject are not collapsed into one polymorphic ID. A signed-in employee acting for an organization can be represented explicitly:
|
|
1141
|
-
|
|
1142
922
|
```ruby
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
http_request: request,
|
|
1150
|
-
submission: clickwrap_submission
|
|
1151
|
-
)
|
|
923
|
+
class ApplicationController < ActionController::Base
|
|
924
|
+
# Nobody gets past this until they've accepted the current Terms. Clickwrap's
|
|
925
|
+
# own acceptance, receipt, withdrawal, and document screens stay reachable
|
|
926
|
+
# automatically, so this cannot redirect-loop its remediation page.
|
|
927
|
+
requires_clickwrap :current_terms
|
|
928
|
+
end
|
|
1152
929
|
```
|
|
1153
930
|
|
|
1154
|
-
|
|
931
|
+
Publish the new version and every signed-in user gets redirected to the acceptance screen on their next request — and sent back to wherever they were going the moment they accept. Preview the blast radius before you activate it:
|
|
932
|
+
|
|
933
|
+
```bash
|
|
934
|
+
bin/rails clickwrap:reacceptance:plan POLICY=current_terms
|
|
935
|
+
```
|
|
1155
936
|
|
|
1156
|
-
|
|
937
|
+
What you get for free: the new acceptance supersedes the old one (`agreed → superseded`) without rewriting anything, and every receipt pins the exact version, locale, and byte digest of what each user agreed to — so "which exact Terms did this person accept, and when?" stays answerable years later.
|
|
1157
938
|
|
|
1158
|
-
|
|
939
|
+
Want to wall off only *parts* of the app instead? Gates are per-controller and per-action, and different areas can require different policies:
|
|
1159
940
|
|
|
1160
941
|
```ruby
|
|
1161
|
-
|
|
942
|
+
class BillingController < ApplicationController
|
|
943
|
+
requires_clickwrap :current_terms
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
class Api::DashboardController < ApplicationController
|
|
947
|
+
requires_clickwrap :developer_terms, only: %i[show update]
|
|
948
|
+
end
|
|
1162
949
|
```
|
|
1163
950
|
|
|
1164
|
-
|
|
951
|
+
### "I agree" before the account even exists — signup, Google sign-in
|
|
952
|
+
|
|
953
|
+
At signup, people click "I agree" before they have an account with you: there's no `current_user` to hang the acceptance on yet, and the acceptance has to survive account creation. `clickwrap` models this honestly as a *prospective-actor* flow — the acceptance binds to a short-lived signed registration flow, then the account and its acceptance evidence commit in one database transaction, and the receipt records that this was an account registration (not an authenticated session).
|
|
1165
954
|
|
|
1166
|
-
|
|
955
|
+
For plain email/password signup, the Devise and Rails-authentication adapters above already do all of this — `form.clickwrap :signup` in your signup form is the whole integration.
|
|
1167
956
|
|
|
1168
|
-
|
|
957
|
+
For Google sign-in (OAuth, One Tap), the click happens on Google's side, so put the acceptance on a "finish creating your account" screen after the callback:
|
|
1169
958
|
|
|
1170
959
|
```ruby
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
960
|
+
# The OAuth callback doesn't create the account yet — it stashes what Google
|
|
961
|
+
# said and sends the person to finish signing up.
|
|
962
|
+
def google
|
|
963
|
+
session[:pending_oauth] = request.env["omniauth.auth"].slice("provider", "uid", "info")
|
|
964
|
+
redirect_to new_finish_signup_path
|
|
965
|
+
end
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
```erb
|
|
969
|
+
<%# The finish screen: name and email prefilled from Google, plus your Terms. %>
|
|
970
|
+
<%= form_with model: @user, url: finish_signup_path do |form| %>
|
|
971
|
+
<%= form.clickwrap :signup, submit: "Create account" %>
|
|
972
|
+
<% end %>
|
|
1177
973
|
```
|
|
1178
974
|
|
|
1179
|
-
|
|
975
|
+
```ruby
|
|
976
|
+
def create
|
|
977
|
+
@user = User.new(user_attributes_from(session[:pending_oauth]))
|
|
1180
978
|
|
|
1181
|
-
|
|
979
|
+
# Account + acceptance commit together, or neither happens. A refused
|
|
980
|
+
# submission re-renders the finish screen with the reason beside the control.
|
|
981
|
+
unless register_with_clickwrap(:signup, user: @user) { @user.save! }
|
|
982
|
+
return render :new, status: :unprocessable_entity
|
|
983
|
+
end
|
|
1182
984
|
|
|
1183
|
-
|
|
985
|
+
session.delete(:pending_oauth)
|
|
986
|
+
sign_in @user
|
|
987
|
+
redirect_to root_path
|
|
988
|
+
end
|
|
989
|
+
```
|
|
1184
990
|
|
|
1185
|
-
The
|
|
991
|
+
The registration flow lives in your session and the presentation token is valid for two hours by default, so both comfortably survive the round-trip to Google and back. One thing `clickwrap` will not do, on purpose: record an agreement from the OAuth callback alone. "By continuing you agree" with no affirmative act isn't evidence of anything — a real acceptance step has to happen somewhere, and the finish screen is where it belongs.
|
|
1186
992
|
|
|
1187
|
-
|
|
1188
|
-
- Turbo Drive and Turbo Frames;
|
|
1189
|
-
- validation re-renders with no JavaScript;
|
|
1190
|
-
- Hotwire Native web screens;
|
|
1191
|
-
- custom native/API presentations; and
|
|
1192
|
-
- operator/admin surfaces.
|
|
993
|
+
### One person accepts for the whole company — organization agreements
|
|
1193
994
|
|
|
1194
|
-
|
|
995
|
+
Your customer is a company — but companies don't click checkboxes, people do. When an admin accepts your business terms "for Acme Inc.", two facts matter and must never blur into each other: the *organization* is the party the terms are for, and a *specific human* performed the acceptance on its behalf. Years later, the question is always the same: exactly which person accepted for the company, and what authority did they have when they did?
|
|
1195
996
|
|
|
1196
|
-
|
|
997
|
+
Declare in the policy who is allowed to accept for an organization — membership alone is deliberately not enough:
|
|
1197
998
|
|
|
1198
|
-
|
|
999
|
+
```ruby
|
|
1000
|
+
Clickwrap.policy :organization_terms do
|
|
1001
|
+
agree_to :business_terms
|
|
1199
1002
|
|
|
1200
|
-
|
|
1003
|
+
permit_acting_for_organization when_actor_is_at_least: :admin
|
|
1201
1004
|
|
|
1202
|
-
|
|
1005
|
+
retain_with :ordinary_agreement_evidence
|
|
1006
|
+
end
|
|
1007
|
+
```
|
|
1203
1008
|
|
|
1204
|
-
|
|
1009
|
+
Make the represented company conspicuous in the UI, and pass it as `acting_for:`:
|
|
1205
1010
|
|
|
1206
|
-
```
|
|
1207
|
-
|
|
1208
|
-
:signup,
|
|
1209
|
-
actor: api_actor,
|
|
1210
|
-
locale: :es,
|
|
1211
|
-
capture_channel: :native_api,
|
|
1212
|
-
submit_button_text: "Crear cuenta"
|
|
1213
|
-
)
|
|
1011
|
+
```erb
|
|
1012
|
+
<p>You are accepting these terms for <strong><%= current_organization.name %></strong>.</p>
|
|
1214
1013
|
|
|
1215
|
-
|
|
1014
|
+
<%= form.clickwrap :organization_terms,
|
|
1015
|
+
acting_for: current_organization,
|
|
1016
|
+
submit: "Accept for #{current_organization.name}" %>
|
|
1216
1017
|
```
|
|
1217
1018
|
|
|
1218
|
-
|
|
1019
|
+
Then capture the acceptance and stamp the organization in one transaction, so the rest of your app can ask a plain domain question:
|
|
1219
1020
|
|
|
1220
1021
|
```ruby
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1022
|
+
def create
|
|
1023
|
+
organization = current_organization
|
|
1024
|
+
|
|
1025
|
+
capture_clickwrap_and!(:organization_terms, acting_for: organization) do |pending_receipt|
|
|
1026
|
+
organization.update!(terms_accepted_with_clickwrap_event_id: pending_receipt.event_id)
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
redirect_to organization_settings_path
|
|
1030
|
+
end
|
|
1228
1031
|
```
|
|
1229
1032
|
|
|
1230
|
-
|
|
1033
|
+
When the form is rendered, `clickwrap` verifies authority and signs that
|
|
1034
|
+
presentation-time source, role, criterion, and verification time into the
|
|
1035
|
+
manifest. At submit it requires a current membership in that exact
|
|
1036
|
+
organization and rereads and locks the membership role *inside* the capture
|
|
1037
|
+
transaction. An admin demoted between render and submit is refused; a still-
|
|
1038
|
+
authorized role change is recorded honestly as two different snapshots. A
|
|
1039
|
+
token rendered for one organization is rejected for another. The receipt keeps
|
|
1040
|
+
the human actor, represented organization, both authority checks, and the
|
|
1041
|
+
protected outcome as separate facts. An organizational acceptance never
|
|
1042
|
+
quietly answers a personal one, and vice versa:
|
|
1231
1043
|
|
|
1232
|
-
|
|
1044
|
+
```ruby
|
|
1045
|
+
user.clickwraps.current_for?(:organization_terms, acting_for: organization) # => true
|
|
1046
|
+
user.clickwraps.current_for?(:organization_terms) # => false
|
|
1047
|
+
```
|
|
1233
1048
|
|
|
1234
|
-
|
|
1049
|
+
That receipt is exactly what you'll be asked to produce if the agreement is ever disputed: who accepted, for which company, in what role, verified when. Whether that role was *sufficient to bind the company* is a question for your counsel when they choose the `when_actor_is_at_least:` criterion — `clickwrap` records the facts that answer it. Works out of the box with the [`organizations`](https://github.com/rameerez/organizations) gem, or with your own authority model via a registered adapter. The [organizations guide](guides/organizations.md) has the full walkthrough.
|
|
1235
1050
|
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
- visible keyboard focus;
|
|
1239
|
-
- high-contrast conventional links;
|
|
1240
|
-
- `aria-invalid` and `aria-describedby` error relationships;
|
|
1241
|
-
- error summary and focus behavior;
|
|
1242
|
-
- keyboard operation;
|
|
1243
|
-
- non-color-only meaning;
|
|
1244
|
-
- no-JavaScript validation;
|
|
1245
|
-
- locale-aware document selection; and
|
|
1246
|
-
- review/correction support for consequential submissions.
|
|
1051
|
+
If the organization does not exist until this same form creates it, opt into
|
|
1052
|
+
that materially different flow explicitly:
|
|
1247
1053
|
|
|
1248
|
-
|
|
1054
|
+
```ruby
|
|
1055
|
+
Clickwrap.policy :organization_creation do
|
|
1056
|
+
declare :authority_and_content_rights,
|
|
1057
|
+
statement: "I am authorized to create and act for this organization and may use the content I submit.",
|
|
1058
|
+
document: nil,
|
|
1059
|
+
protected_outcome_version: "created-organization-v1",
|
|
1060
|
+
record_protected_outcome_with: ->(organization) {
|
|
1061
|
+
Clickwrap.protected_outcome(
|
|
1062
|
+
action: :created,
|
|
1063
|
+
record: organization,
|
|
1064
|
+
facts: { name: organization.name }
|
|
1065
|
+
)
|
|
1066
|
+
}
|
|
1249
1067
|
|
|
1250
|
-
|
|
1068
|
+
permit_acting_for_organization(
|
|
1069
|
+
when_actor_is_at_least: :owner,
|
|
1070
|
+
including_when_this_action_creates_the_organization: true
|
|
1071
|
+
)
|
|
1251
1072
|
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
bin/rails clickwrap:publish:plan
|
|
1255
|
-
bin/rails clickwrap:publish
|
|
1256
|
-
bin/rails clickwrap:reacceptance:plan POLICY=current_terms
|
|
1257
|
-
bin/rails clickwrap:verify
|
|
1258
|
-
bin/rails clickwrap:export EVENT_ID
|
|
1259
|
-
bin/rails clickwrap:retention:plan
|
|
1260
|
-
bin/rails clickwrap:retention:apply PLAN=PLAN_ID
|
|
1261
|
-
bin/rails clickwrap:holds:review
|
|
1262
|
-
bin/rails clickwrap:privacy:inventory
|
|
1263
|
-
bin/rails clickwrap:reconcile_external_actions
|
|
1073
|
+
retain_with :ordinary_agreement_evidence
|
|
1074
|
+
end
|
|
1264
1075
|
```
|
|
1265
1076
|
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
✓ every required gate has a remediation route
|
|
1273
|
-
✓ request-derived personal data is off by default
|
|
1274
|
-
! withdrawal_authorization records IP geolocation city without a review date
|
|
1275
|
-
! Cloudflare source trust is unverified
|
|
1276
|
-
✓ no overdue disposition jobs
|
|
1277
|
-
✓ all checked event digests verify
|
|
1077
|
+
```erb
|
|
1078
|
+
<%= form_with model: @organization do |form| %>
|
|
1079
|
+
<%= form.clickwrap :organization_creation,
|
|
1080
|
+
acting_for: @organization,
|
|
1081
|
+
submit: "Create organization" %>
|
|
1082
|
+
<% end %>
|
|
1278
1083
|
```
|
|
1279
1084
|
|
|
1280
|
-
|
|
1085
|
+
```ruby
|
|
1086
|
+
create_represented_party_with_clickwrap(
|
|
1087
|
+
:organization_creation,
|
|
1088
|
+
represented_party: @organization
|
|
1089
|
+
) do |pending_receipt|
|
|
1090
|
+
@organization.save!
|
|
1091
|
+
@organization.add_member!(current_user, role: :owner)
|
|
1092
|
+
@organization.update!(creation_clickwrap_event_id: pending_receipt.event_id)
|
|
1093
|
+
@organization
|
|
1094
|
+
end
|
|
1095
|
+
```
|
|
1281
1096
|
|
|
1282
|
-
|
|
1097
|
+
The form helper creates a server-owned browser-flow binding automatically. The
|
|
1098
|
+
manifest says authority is `not_yet_verifiable` because the membership does not
|
|
1099
|
+
exist yet; after the protected block returns the persisted organization and
|
|
1100
|
+
creates its owner membership, the adapter verifies them and Clickwrap rebinds the final GlobalID
|
|
1101
|
+
before commit. If any part fails, none of the organization, membership,
|
|
1102
|
+
evidence, or protected outcome commits. The explicit declaration is still what
|
|
1103
|
+
records the human's claim of pre-existing real-world authority: an owner role
|
|
1104
|
+
created by the transaction proves an application fact, not the truth or legal
|
|
1105
|
+
sufficiency of that claim.
|
|
1283
1106
|
|
|
1284
|
-
## Testing
|
|
1107
|
+
## Testing your integration
|
|
1285
1108
|
|
|
1286
|
-
|
|
1109
|
+
Documents must be published in the test database too — presentations refuse unpublished documents in tests exactly as in production:
|
|
1287
1110
|
|
|
1288
1111
|
```ruby
|
|
1112
|
+
# test/test_helper.rb
|
|
1289
1113
|
class ActiveSupport::TestCase
|
|
1290
1114
|
include Clickwrap::TestHelpers
|
|
1115
|
+
parallelize_setup { Clickwrap.publish! } # once per parallel worker...
|
|
1291
1116
|
end
|
|
1117
|
+
Clickwrap.publish! # ...and once per process
|
|
1292
1118
|
```
|
|
1293
1119
|
|
|
1294
|
-
Create real, internally consistent test evidence without knowing table details:
|
|
1295
|
-
|
|
1296
1120
|
```ruby
|
|
1297
|
-
receipt =
|
|
1298
|
-
:signup,
|
|
1299
|
-
actor: user,
|
|
1300
|
-
answers: { terms: true, privacy_notice: true }
|
|
1301
|
-
)
|
|
1121
|
+
receipt = submit_clickwrap(:signup, actor: user, answers: { terms: true, privacy_notice: true })
|
|
1302
1122
|
|
|
1303
1123
|
assert_clickwrap_current :signup, actor: user
|
|
1304
1124
|
assert_clickwrap_agreed_to :terms, actor: user
|
|
1305
|
-
assert_clickwrap_acknowledged :privacy_notice, actor: user
|
|
1306
1125
|
assert_clickwrap_receipt_verifies receipt
|
|
1307
1126
|
```
|
|
1308
1127
|
|
|
1309
|
-
|
|
1128
|
+
`submit_clickwrap` is the test factory: it presents the policy through the real presenter, answers it, and captures — and it *raises* when the capture is refused, because in a test a failed capture is a failed test. That is deliberately a different verb from the controller's `capture_clickwrap`, which captures a submission a person actually sent and absorbs refusals into `false`. Same word for both would mean one name with two opposite answers to "what happens when this is refused".
|
|
1129
|
+
|
|
1130
|
+
Integration tests can't fabricate a signed presentation token by hand — that's the point — so they read it off the rendered page the way a browser does:
|
|
1131
|
+
|
|
1132
|
+
```ruby
|
|
1133
|
+
post user_registration_path, params: {
|
|
1134
|
+
user: { email: "person@example.com", password: "a-real-password" },
|
|
1135
|
+
**clickwrap_params_from(new_user_registration_path) # GET the page, affirm everything
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
# Decline one statement instead:
|
|
1139
|
+
declined = clickwrap_params_from(new_user_registration_path, answers: { terms: false })
|
|
1140
|
+
|
|
1141
|
+
# Choice statements submit their real rendered values. By default the helper
|
|
1142
|
+
# selects the first offered radio choice; name a different choice explicitly:
|
|
1143
|
+
contractor = clickwrap_params_from(
|
|
1144
|
+
new_user_registration_path,
|
|
1145
|
+
answers: { employment_kind: "contractor" }
|
|
1146
|
+
)
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1149
|
+
Checkbox statements default to their affirmative value. Radio statements
|
|
1150
|
+
default to the first choice rendered by the application, so tests exercise a
|
|
1151
|
+
value the server actually offered instead of a fabricated checkbox value.
|
|
1152
|
+
Pass the exact choice key when the choice matters. For a conventional
|
|
1153
|
+
`yes`/`no` radio group, `false` selects `no`; explicit choice keys remain the
|
|
1154
|
+
clearest option for domain-specific choices.
|
|
1155
|
+
|
|
1156
|
+
If one page renders several independent Clickwrap forms, select the exact form;
|
|
1157
|
+
the helper refuses an ambiguous page instead of combining one form's token with
|
|
1158
|
+
another form's answers:
|
|
1310
1159
|
|
|
1311
1160
|
```ruby
|
|
1312
|
-
|
|
1313
|
-
|
|
1161
|
+
submission = clickwrap_submission_params_from(
|
|
1162
|
+
response,
|
|
1163
|
+
form_css_selector: "form[action='/withdrawals/confirm']"
|
|
1164
|
+
)
|
|
1314
1165
|
```
|
|
1315
1166
|
|
|
1316
|
-
Fault injection proves
|
|
1167
|
+
Fault injection proves the atomicity claim in your own suite:
|
|
1317
1168
|
|
|
1318
1169
|
```ruby
|
|
1319
1170
|
Clickwrap::Testing.fail_next_event_write do
|
|
1320
|
-
assert_raises(Clickwrap::EventWriteFailed)
|
|
1321
|
-
perform_signup
|
|
1322
|
-
end
|
|
1171
|
+
assert_raises(Clickwrap::EventWriteFailed) { perform_signup }
|
|
1323
1172
|
end
|
|
1324
|
-
|
|
1325
1173
|
assert_not User.exists?(email: "person@example.com")
|
|
1326
|
-
assert_no_clickwrap_event :signup
|
|
1327
1174
|
```
|
|
1328
1175
|
|
|
1329
|
-
|
|
1176
|
+
## Configuration
|
|
1330
1177
|
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
The complete initializer is annotated in plain English. A representative configuration looks like:
|
|
1178
|
+
The generated initializer is fully annotated and every setting reads like a sentence. The essentials:
|
|
1334
1179
|
|
|
1335
1180
|
```ruby
|
|
1336
1181
|
# config/initializers/clickwrap.rb
|
|
1337
1182
|
Clickwrap.configure do |config|
|
|
1338
1183
|
config.actor_class_name = "User"
|
|
1339
1184
|
config.current_actor_method_name = :current_user
|
|
1340
|
-
config.parent_controller_class_name = "ApplicationController"
|
|
1341
|
-
|
|
1342
|
-
config.find_current_tenant_with = lambda do |controller|
|
|
1343
|
-
controller.current_organization if controller.respond_to?(:current_organization)
|
|
1344
|
-
end
|
|
1345
1185
|
|
|
1346
1186
|
config.authorize_receipt_access_with = lambda do |controller, receipt|
|
|
1347
1187
|
controller.current_user == receipt.actor
|
|
1348
1188
|
end
|
|
1349
1189
|
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
controller.current_user&.security_operator? && because.present?
|
|
1353
|
-
end
|
|
1354
|
-
|
|
1355
|
-
config.identify_actor_with = ->(actor) { actor.to_gid.to_s }
|
|
1356
|
-
# Add only reviewed fields your receipts truly need; never serialize the model.
|
|
1357
|
-
config.snapshot_actor_with = ->(_actor) { {} }
|
|
1358
|
-
config.describe_authentication_with = lambda do |controller|
|
|
1359
|
-
{ method: :authenticated_session, authenticated_at: controller.session[:authenticated_at] }
|
|
1360
|
-
end
|
|
1190
|
+
# Safe defaults: no IP address, browser user-agent, or IP geolocation is stored.
|
|
1191
|
+
# Enable fields per policy, each with a plain-English purpose and retention rule.
|
|
1361
1192
|
|
|
1362
|
-
|
|
1363
|
-
config.digest_canonical_receipts_with = :sha256
|
|
1364
|
-
config.chain_event_history_with = nil
|
|
1365
|
-
config.anchor_event_history_with = nil
|
|
1366
|
-
config.timestamp_receipts_with = nil
|
|
1367
|
-
config.application_version = -> { ENV["RELEASE_SHA"] }
|
|
1368
|
-
|
|
1369
|
-
# Safe defaults: no raw network/browser/geolocation data is stored.
|
|
1370
|
-
config.record_ip_address_by_default = false
|
|
1371
|
-
config.record_browser_user_agent_by_default = false
|
|
1372
|
-
config.record_ip_geolocation_country_by_default = false
|
|
1373
|
-
config.record_ip_geolocation_region_by_default = false
|
|
1374
|
-
config.record_ip_geolocation_city_by_default = false
|
|
1375
|
-
config.record_ip_geolocation_postal_code_by_default = false
|
|
1376
|
-
config.record_ip_geolocation_latitude_and_longitude_by_default = false
|
|
1377
|
-
config.record_ip_geolocation_timezone_by_default = false
|
|
1378
|
-
config.record_ip_geolocation_continent_by_default = false
|
|
1379
|
-
config.record_ip_geolocation_metro_code_by_default = false
|
|
1380
|
-
config.record_ip_geolocation_accuracy_radius_in_kilometers_by_default = false
|
|
1381
|
-
|
|
1382
|
-
# If a default above becomes true, fill in the matching plain-English
|
|
1383
|
-
# reason and a retention rule below. The policy compiler rejects an
|
|
1384
|
-
# enabled default whose purpose or retention is blank.
|
|
1385
|
-
config.reason_for_recording_ip_addresses_by_default = nil
|
|
1386
|
-
config.reason_for_recording_browser_user_agents_by_default = nil
|
|
1387
|
-
config.reason_for_recording_ip_geolocation_by_default = nil
|
|
1388
|
-
config.legal_basis_reference_for_recording_ip_addresses_by_default = nil
|
|
1389
|
-
config.legal_basis_reference_for_recording_browser_user_agents_by_default = nil
|
|
1390
|
-
config.legal_basis_reference_for_recording_ip_geolocation_by_default = nil
|
|
1391
|
-
config.review_default_request_evidence_configuration_on = nil
|
|
1392
|
-
|
|
1393
|
-
config.encrypt_recorded_ip_addresses = true
|
|
1394
|
-
config.encrypt_recorded_browser_user_agents = true
|
|
1395
|
-
config.encrypt_recorded_ip_geolocation = true
|
|
1396
|
-
|
|
1397
|
-
# Nil means every policy that enables the field must supply its own rule.
|
|
1398
|
-
config.delete_recorded_ip_addresses_after = nil
|
|
1399
|
-
config.delete_recorded_browser_user_agents_after = nil
|
|
1400
|
-
config.delete_recorded_ip_geolocation_after = nil
|
|
1401
|
-
|
|
1402
|
-
config.read_ip_address_from_http_request_with =
|
|
1403
|
-
->(http_request) { http_request.remote_ip }
|
|
1404
|
-
|
|
1405
|
-
config.read_browser_user_agent_from_http_request_with =
|
|
1406
|
-
->(http_request) { http_request.user_agent }
|
|
1407
|
-
|
|
1408
|
-
config.ip_geolocation_resolver = nil
|
|
1409
|
-
config.fail_capture_when_ip_geolocation_is_unavailable = false
|
|
1410
|
-
|
|
1411
|
-
# Runs only after required evidence and domain state have committed.
|
|
1412
|
-
# Hook failures are reported but can never undo the committed action.
|
|
1193
|
+
# Optional hooks run only after evidence and domain state have committed:
|
|
1413
1194
|
config.after_event_is_committed = ->(event) { }
|
|
1414
|
-
config.report_after_commit_failure_with = ->(error, event) { Rails.error.report(error) }
|
|
1415
1195
|
end
|
|
1416
1196
|
```
|
|
1417
1197
|
|
|
1418
|
-
Every
|
|
1198
|
+
Only your decisions are live in that file. Every setting left at the gem's default appears commented with its value, under prose explaining what it does, so a reader can tell at a glance which lines somebody chose. The one deliberate exception is the request-evidence block: each `record_*_by_default` line is written even when it says `false`, because each is an answer to a question the installer asked, and "we decided not to collect this" is worth reading rather than inferring from a file that does not mention it.
|
|
1419
1199
|
|
|
1420
|
-
|
|
1200
|
+
Class names are strings resolved lazily for autoloading, and ambiguity fails at boot instead of becoming a surprising runtime default. Optional external integrations are explicit: anchoring and timestamping are off (`nil`) until an adapter is configured; optional hook procs have working no-op defaults; and geolocation/document integrations run only when their corresponding policy or storage choice asks for them.
|
|
1421
1201
|
|
|
1422
|
-
|
|
1423
|
-
bin/rails generate clickwrap:install
|
|
1424
|
-
bin/rails generate clickwrap:policy driver_declaration
|
|
1425
|
-
bin/rails generate clickwrap:document terms
|
|
1426
|
-
bin/rails generate clickwrap:views
|
|
1427
|
-
bin/rails generate clickwrap:hardening --database
|
|
1428
|
-
bin/rails generate clickwrap:upgrade
|
|
1429
|
-
```
|
|
1430
|
-
|
|
1431
|
-
The installer:
|
|
1432
|
-
|
|
1433
|
-
- detects integer/UUID keys and supported database features;
|
|
1434
|
-
- detects Rails authentication and Devise without making either a hard dependency;
|
|
1435
|
-
- stops and explains itself when actor/tenant mappings are ambiguous;
|
|
1436
|
-
- asks before wiring signup or mounting routes;
|
|
1437
|
-
- asks separately about every request-evidence field;
|
|
1438
|
-
- writes plain-English purposes and retention placeholders that must be reviewed;
|
|
1439
|
-
- never overwrites host files without normal Rails generator conflict handling; and
|
|
1440
|
-
- prints a post-install checklist for documents, semantics, privacy, retention, trusted proxies, full-page UI review, and tests.
|
|
1441
|
-
|
|
1442
|
-
Upgrade generators create new migrations. Released migrations are never silently edited underneath an application.
|
|
1443
|
-
|
|
1444
|
-
## Migrate without inventing history
|
|
1445
|
-
|
|
1446
|
-
### From FinePrint
|
|
1447
|
-
|
|
1448
|
-
Preview first:
|
|
1449
|
-
|
|
1450
|
-
```bash
|
|
1451
|
-
bin/rails clickwrap:import:fine_print:plan
|
|
1452
|
-
```
|
|
1453
|
-
|
|
1454
|
-
Then import:
|
|
1455
|
-
|
|
1456
|
-
```bash
|
|
1457
|
-
bin/rails clickwrap:import:fine_print
|
|
1458
|
-
```
|
|
1202
|
+
### The presentation linter
|
|
1459
1203
|
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
### From `accepted_terms_at`
|
|
1463
|
-
|
|
1464
|
-
```ruby
|
|
1465
|
-
Clickwrap.import_legacy!(
|
|
1466
|
-
:terms,
|
|
1467
|
-
actor: user,
|
|
1468
|
-
occurred_at: user.accepted_terms_at,
|
|
1469
|
-
known: {
|
|
1470
|
-
document_version: user.terms_version
|
|
1471
|
-
},
|
|
1472
|
-
unknown: %i[
|
|
1473
|
-
exact_document_bytes
|
|
1474
|
-
presentation
|
|
1475
|
-
assertion
|
|
1476
|
-
submit_button_text
|
|
1477
|
-
request_evidence
|
|
1478
|
-
],
|
|
1479
|
-
because: "Imported from users.accepted_terms_at"
|
|
1480
|
-
)
|
|
1481
|
-
```
|
|
1482
|
-
|
|
1483
|
-
Imports are append-only, provenance-labeled, idempotent, dry-runnable, and report every unknown. Historical weakness remains visible instead of being laundered into modern certainty.
|
|
1484
|
-
|
|
1485
|
-
## Extension seams, not dependency soup
|
|
1486
|
-
|
|
1487
|
-
The core has small adapter contracts for:
|
|
1488
|
-
|
|
1489
|
-
- document storage;
|
|
1490
|
-
- actor/tenant resolution;
|
|
1491
|
-
- identity/authentication snapshots;
|
|
1492
|
-
- IP geolocation;
|
|
1493
|
-
- independent checkpoints/anchors;
|
|
1494
|
-
- RFC 3161 or trust-service timestamps;
|
|
1495
|
-
- external clickwrap/signature providers;
|
|
1496
|
-
- object-lock/WORM storage;
|
|
1497
|
-
- PDF rendering;
|
|
1498
|
-
- authorization;
|
|
1499
|
-
- error reporting;
|
|
1500
|
-
- notifications; and
|
|
1501
|
-
- post-commit analytics/auditing.
|
|
1502
|
-
|
|
1503
|
-
Every optional adapter has a no-op default and explicit capability reporting. Installing Clickwrap never pulls in Redis, Sidekiq, Devise, Trackdown, Active Storage, a PDF library, a cloud SDK, or an external service unless the application chooses that integration.
|
|
1504
|
-
|
|
1505
|
-
ActiveSupport notifications are available for instrumentation:
|
|
1204
|
+
In development and test, every render is scanned for the mistakes a form can make silently — a preselected consent control, a consent sentence carrying two purposes, a document link below the submit button, a missing presentation token. Findings go to the log as warnings and never raise: a lint finding is a thing to look at, not a reason to stop a page from rendering. It is off in production, because a production request has no business scanning its own HTML on the way out.
|
|
1506
1205
|
|
|
1507
1206
|
```ruby
|
|
1508
|
-
|
|
1509
|
-
# event payload contains stable IDs and categories, not raw request evidence
|
|
1510
|
-
end
|
|
1207
|
+
config.lint_presentations = false # or true to run it in another environment
|
|
1511
1208
|
```
|
|
1512
1209
|
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
## What Clickwrap does, what your application owns, and what the receipt proves
|
|
1516
|
-
|
|
1517
|
-
| Area | Clickwrap provides | Your application/counsel owns | Receipt/evidence |
|
|
1518
|
-
|---|---|---|---|
|
|
1519
|
-
| Documents | immutable versions, bytes/digests, locales, publication | text, translation, fairness, legal approval, materiality | exact stored version and digest |
|
|
1520
|
-
| Presentation | tested controls/helper, manifest, token, stale/replay checks | whole-page placement/design, final CTA, accessibility review | server-generated manifest and accepted answers |
|
|
1521
|
-
| Actor | configured reference and authentication snapshot | identity proofing, capacity, authority, guardian/organization rules | exactly which configured actor/context was recorded |
|
|
1522
|
-
| Agreements | version/current-state mechanics | enforceability, governing law, substantive terms | agreement event and historical version |
|
|
1523
|
-
| Privacy notice | acknowledgment mechanics | transparency content and lawful basis for processing | notice version and acknowledgment event |
|
|
1524
|
-
| Consent | purposes, grant/withdrawal/renewal lifecycle | whether consent is the correct basis and whether it is freely given | exact grant/withdrawal history |
|
|
1525
|
-
| Declarations | statement snapshot, expiry/correction/supersession | truth, eligibility, domain validation | what was declared, when, for which subject |
|
|
1526
|
-
| Authorizations | scope, fingerprint, freshness, one-time consumption | domain permission and external-provider consequences | exact evidence-to-outcome binding |
|
|
1527
|
-
| Request evidence | explicit capture, provenance, encryption/redaction/disposition | necessity, lawful basis, disclosure, trusted proxy/source, period | selected fields and honest source/state |
|
|
1528
|
-
| Integrity | canonical digests, verification, optional chains/adapters | keys, infrastructure, access controls, backups, operational procedures | verification result and bounded assurance tier |
|
|
1529
|
-
| Retention | executable rules, holds, dry-run disposition | legally appropriate periods and case-specific holds | retention/hold/disposition history |
|
|
1210
|
+
`nil` (the default) means "decide from the environment".
|
|
1530
1211
|
|
|
1531
|
-
|
|
1212
|
+
## Operations
|
|
1532
1213
|
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
- guarantee enforceability, admissibility, accessibility, or audit acceptance;
|
|
1541
|
-
- verify identity, age, capacity, guardianship, or organizational authority;
|
|
1542
|
-
- provide KYC, sanctions screening, fraud scoring, or biometrics;
|
|
1543
|
-
- become a cookie CMP, tracker scanner, or script blocker;
|
|
1544
|
-
- become DocuSign, Ironclad, a notary, a qualified trust-service provider, or a contract lifecycle platform;
|
|
1545
|
-
- call a local hash tamper-proof;
|
|
1546
|
-
- call an IP address identity or IP geolocation physical location;
|
|
1547
|
-
- require forced scrolling or claim it proves reading;
|
|
1548
|
-
- require a sprawling admin/document-authoring suite; or
|
|
1549
|
-
- hide collection behind `compliant: true` or `maximum_evidence: true`.
|
|
1550
|
-
|
|
1551
|
-
Adapters let those systems contribute provider receipts without changing what Clickwrap itself claims.
|
|
1552
|
-
|
|
1553
|
-
## FinePrint and Clickwrap solve different-sized problems
|
|
1554
|
-
|
|
1555
|
-
[FinePrint](https://github.com/openstax/fine_print/blob/3b75fbcbcfb048ecd2f4ee7c4f0b9bd3d10f7603/README.md#L7-L25) is established Rails prior art for versioned contracts, signatures, gates, and views. Clickwrap should never market itself as the first Rails agreement gem.
|
|
1556
|
-
|
|
1557
|
-
FinePrint’s documented core and [signature model at the audited commit](https://github.com/openstax/fine_print/blob/3b75fbcbcfb048ecd2f4ee7c4f0b9bd3d10f7603/app/models/fine_print/signature.rb#L1-L33) answer:
|
|
1558
|
-
|
|
1559
|
-
```text
|
|
1560
|
-
Did user U sign version N of contract X?
|
|
1561
|
-
```
|
|
1562
|
-
|
|
1563
|
-
Clickwrap is for applications that also need to answer:
|
|
1564
|
-
|
|
1565
|
-
```text
|
|
1566
|
-
Which exact content and presentation was offered?
|
|
1567
|
-
Which explicit statements and choices were made?
|
|
1568
|
-
Did the required evidence and protected outcome commit together?
|
|
1569
|
-
What subject or transaction did it cover?
|
|
1570
|
-
Was it withdrawn, corrected, superseded, expired, or consumed?
|
|
1571
|
-
Can the complete receipt be reproduced and verified independently?
|
|
1572
|
-
Can optional personal request evidence be disposed of honestly?
|
|
1214
|
+
```bash
|
|
1215
|
+
bin/rails clickwrap:doctor # objective health report, never prints "compliant"
|
|
1216
|
+
bin/rails clickwrap:publish # freeze document snapshots (idempotent; also rides db:prepare)
|
|
1217
|
+
bin/rails clickwrap:verify # verify event digests
|
|
1218
|
+
bin/rails clickwrap:retention:plan # preview disposition
|
|
1219
|
+
bin/rails clickwrap:privacy:inventory # every configured personal-data field, purpose, and rule
|
|
1220
|
+
bin/rails clickwrap:import:fine_print # migrate from FinePrint without inventing history
|
|
1573
1221
|
```
|
|
1574
1222
|
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
## Legal and evidentiary posture
|
|
1578
|
-
|
|
1579
|
-
Electronic form does not cure an invalid underlying transaction, missing capacity/authority, or a special formality. The US E-SIGN Act preserves electronic validity while retaining substantive requirements and exclusions ([15 U.S.C. § 7001](https://www.law.cornell.edu/uscode/text/15/7001); [15 U.S.C. § 7003](https://www.law.cornell.edu/uscode/text/15/7003)). Electronic form also does not make an unfair term fair ([Directive 93/13/EEC](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=celex%3A31993L0013)). EU eIDAS distinguishes ordinary electronic evidence from qualified electronic signatures and their specific legal effect ([Regulation (EU) No 910/2014, Article 25](https://eur-lex.europa.eu/eli/reg/2014/910/2024-05-20/eng)).
|
|
1580
|
-
|
|
1581
|
-
US appellate formation decisions evaluate conspicuous notice and unambiguous assent in the context of the whole interface; no checkbox color or placement is a universal safe harbor ([Berman v. Freedom Financial Network](https://cdn.ca9.uscourts.gov/datastore/opinions/2022/04/05/20-16900.pdf); [Tejon v. Zeus Networks](https://media.ca11.uscourts.gov/opinions/pub/files/202411114.pdf); [Toth v. Everly Well](https://www.ca1.uscourts.gov/sites/ca1/files/opnfiles/23-1727P-01A.pdf)).
|
|
1582
|
-
|
|
1583
|
-
GDPR consent must be demonstrable, distinguishable, and withdrawable, but consent is only one possible lawful basis. A privacy-information acknowledgment is not blanket consent ([GDPR Article 6](https://eur-lex.europa.eu/eli/reg/2016/679/art_6/oj/eng); [GDPR Article 7](https://eur-lex.europa.eu/eli/reg/2016/679/art_7/oj/eng); [AEPD FAQ 02.48](https://www.aepd.es/preguntas-frecuentes/2-tus-obligaciones-como-responsable-del-tratamiento/6-el-deber-de-informacion/FAQ-0248-sobre-si-el-usuario-tiene-que-dar-consentimiento-a-clausula-de-privacidad)). GDPR also requires purpose limitation, data minimization, storage limitation, transparency, and security; “collect everything forever” is not the evidence-maximizing default ([Article 5](https://eur-lex.europa.eu/eli/reg/2016/679/art_5/oj/eng); [Article 13](https://eur-lex.europa.eu/eli/reg/2016/679/art_13/oj/eng); [Article 32](https://eur-lex.europa.eu/eli/reg/2016/679/art_32/oj/eng)).
|
|
1584
|
-
|
|
1585
|
-
These sources motivate Clickwrap’s design. They do not turn the gem into legal advice or a universal safe harbor.
|
|
1223
|
+
Migrating from FinePrint or a bare `accepted_terms_at` column? Clickwrap's importer appends provenance-labeled events through its supported API: fields the old system never recorded stay `unknown` instead of being laundered into modern certainty. Direct database privileges remain outside that API's boundary. See the [migration guide](guides/migrating.md).
|
|
1586
1224
|
|
|
1587
|
-
##
|
|
1225
|
+
## Will this hold up in court?
|
|
1588
1226
|
|
|
1589
|
-
|
|
1227
|
+
Here's the honest version, in plain words, because you deserve better than marketing copy on this question.
|
|
1590
1228
|
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
- duplicate/concurrent submits;
|
|
1597
|
-
- mutable document sources;
|
|
1598
|
-
- after-commit analytics and provider callbacks; and
|
|
1599
|
-
- imported evidence without provider provenance.
|
|
1229
|
+
Electronic form alone is not a reason to deny a contract legal effect under the US E-SIGN Act
|
|
1230
|
+
([15 U.S.C. § 7001](https://www.law.cornell.edu/uscode/text/15/7001)), and the EU's eIDAS
|
|
1231
|
+
regulation says an electronic signature may not be denied legal effect or admissibility solely
|
|
1232
|
+
because it is electronic or not qualified ([Regulation 910/2014, Article 25](https://eur-lex.europa.eu/eli/reg/2014/910/2024-05-20/eng)).
|
|
1233
|
+
That does not decide what happens around the control in a particular downstream application:
|
|
1600
1234
|
|
|
1601
|
-
|
|
1235
|
+
**Courts read your whole page, not your checkbox.** In *Berman v. Freedom Financial Network* (a 2022 Ninth Circuit decision, [opinion](https://cdn.ca9.uscourts.gov/datastore/opinions/2022/04/05/20-16900.pdf)), the terms lost: the notice was in tiny gray font, the links to the terms didn't look like links, and the button said "Continue" without mentioning them — even though an acceptance flow existed. Other federal appeals courts run the same whole-interface analysis (*[Tejon v. Zeus Networks](https://media.ca11.uscourts.gov/opinions/pub/files/202411114.pdf)*, *[Toth v. Everly Well](https://www.ca1.uscourts.gov/sites/ca1/files/opnfiles/23-1727P-01A.pdf)*). Placement, font size, contrast, clutter, the words on the button: all decided by *your* page. `clickwrap` renders one accessible, initially-unselected component and records exactly what that component said — it cannot see, or fix, the rest of your screen.
|
|
1602
1236
|
|
|
1603
|
-
|
|
1237
|
+
**The words in your documents matter more than the click.** In the EU, an unfair term in a consumer contract doesn't bind the consumer even when the assent flow was otherwise effective ([Directive 93/13/EEC](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=celex%3A31993L0013)). A strong record of acceptance does not change the underlying term. The gem records your words; it can't make them fair.
|
|
1604
1238
|
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
- Ruby 3.2 through current Ruby, tested explicitly;
|
|
1610
|
-
- Rails 7.1 through current Rails 8.x;
|
|
1611
|
-
- PostgreSQL, SQLite, and MySQL for all documented portable core behavior;
|
|
1612
|
-
- adapter-specific hardening clearly marked and tested;
|
|
1613
|
-
- Rails authentication and Devise, both optional integrations;
|
|
1614
|
-
- Turbo/Hotwire and ordinary HTML;
|
|
1615
|
-
- integer and UUID primary keys;
|
|
1616
|
-
- multi-database applications when evidence and protected action share the documented transaction boundary; and
|
|
1617
|
-
- API-only applications for model/service/JSON receipt APIs, with HTML engine mounting optional.
|
|
1618
|
-
|
|
1619
|
-
The gem depends only on the Rails components its approved surface needs. It does not depend on the `rails` meta-gem, Redis, a job backend, a JavaScript runtime, an external provider, or a CSS framework.
|
|
1239
|
+
**Who acted, and in which capacity.** `clickwrap` records the actor and the authentication and
|
|
1240
|
+
authority facts your application supplies; it does not establish identity, capacity, or legal
|
|
1241
|
+
authority. For an organization, it keeps the human actor distinct from the represented party and
|
|
1242
|
+
records the role or permission criterion your application checked—[see the recipe](#one-person-accepts-for-the-whole-company--organization-agreements).
|
|
1620
1243
|
|
|
1621
|
-
|
|
1244
|
+
**Your jurisdiction and your document type.** The US E-SIGN Act expressly excludes categories
|
|
1245
|
+
including wills, specified family-law matters, and specified notices
|
|
1246
|
+
([15 U.S.C. § 7003](https://www.law.cornell.edu/uscode/text/15/7003)). In the EU, a
|
|
1247
|
+
*qualified* electronic signature has the equivalent legal effect of a handwritten signature;
|
|
1248
|
+
Article 25 separately says other electronic signatures may not be denied legal effect or
|
|
1249
|
+
admissibility solely because they are electronic or not qualified
|
|
1250
|
+
([eIDAS Article 25](https://eur-lex.europa.eu/eli/reg/2014/910/2024-05-20/eng)).
|
|
1251
|
+
This gem does not produce or claim a qualified electronic signature.
|
|
1622
1252
|
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
Clickwrap follows semantic versioning for its documented Ruby/Rails APIs, but persisted evidence gets a stricter promise:
|
|
1253
|
+
**And GDPR consent is its own animal.** Consent has to be demonstrable and withdrawable ([GDPR Article 7](https://eur-lex.europa.eu/eli/reg/2016/679/art_7/oj/eng)) — `clickwrap` gives you both mechanics — but merely acknowledging a privacy notice is not consent (regulator guidance from Spain's AEPD, [FAQ 02.48](https://www.aepd.es/preguntas-frecuentes/2-tus-obligaciones-como-responsable-del-tratamiento/6-el-deber-de-informacion/FAQ-0248-sobre-si-el-usuario-tiene-que-dar-consentimiento-a-clausula-de-privacidad)). That's why `acknowledge` and `consent_to` are different verbs here, with different lifecycles.
|
|
1626
1254
|
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
-
|
|
1633
|
-
|
|
1255
|
+
Notice what's left after all of that: **evidence**. Those opinions examine what interface the
|
|
1256
|
+
application offered and what action it recorded—not whether checkboxes are valid in the abstract.
|
|
1257
|
+
Which exact version of the terms did the server bind to the form? What did the presentation
|
|
1258
|
+
manifest say beside the control? Was the control initially unselected? Which explicit submission
|
|
1259
|
+
did the server accept? Was consent later withdrawn? Most apps genuinely cannot reconstruct those
|
|
1260
|
+
application-side facts; `clickwrap` exists so you can, with a receipt verifiable without the
|
|
1261
|
+
producing application's source code. It still does not prove that a person perceived or
|
|
1262
|
+
understood the interface.
|
|
1634
1263
|
|
|
1635
|
-
|
|
1264
|
+
That's also why nothing in this gem prints "legally binding" or "court-proof": those are conclusions a court reaches about *your* agreement, under *your* jurisdiction's law, looking at *your* whole page and *your* terms. The gem's job is narrower and more useful — making sure that when that day comes, your lawyer is holding the receipt.
|
|
1636
1265
|
|
|
1637
|
-
##
|
|
1266
|
+
## What `clickwrap` is *not*
|
|
1638
1267
|
|
|
1639
|
-
|
|
1268
|
+
This gem provides evidence mechanics — excellent ones — and nothing else. It does not:
|
|
1640
1269
|
|
|
1641
|
-
|
|
1270
|
+
- draft or approve legal documents, or decide whether a change is "material";
|
|
1271
|
+
- claim compliance, enforceability, admissibility, or "court-proof" anything;
|
|
1272
|
+
- verify identity or age, or decide whether a configured role or permission is
|
|
1273
|
+
legally sufficient to bind an organization (identity, KYC, and legal capacity
|
|
1274
|
+
belong elsewhere);
|
|
1275
|
+
- become DocuSign, a notary, a cookie CMP, or a contract-lifecycle platform;
|
|
1276
|
+
- call a local hash tamper-proof, server time trusted time, or an IP address a person;
|
|
1277
|
+
- hide data collection behind a `compliant: true` switch.
|
|
1642
1278
|
|
|
1643
|
-
|
|
1279
|
+
Your application and its counsel own the legal text, lawful basis, retention periods, and jurisdiction-specific requirements. `clickwrap` makes configured decisions executable and traceable in evidence—it doesn't make them for you.
|
|
1644
1280
|
|
|
1645
1281
|
## FAQ
|
|
1646
1282
|
|
|
1647
1283
|
### Is this an electronic-signature gem?
|
|
1648
1284
|
|
|
1649
|
-
It captures electronic evidence of explicit actions and can import
|
|
1650
|
-
|
|
1651
|
-
### Does a user have to open or scroll through the document?
|
|
1652
|
-
|
|
1653
|
-
Not by universal default. Clickwrap makes the document available before action and records the exact presentation. A policy can require an accurately observed open/review interaction when the host has a real requirement, but Clickwrap never equates scrolling with reading or understanding.
|
|
1285
|
+
It captures electronic evidence of explicit actions and can import provider signature receipts. It does not call a checkbox a qualified electronic signature.
|
|
1654
1286
|
|
|
1655
|
-
###
|
|
1287
|
+
### Does the user have to scroll through the document?
|
|
1656
1288
|
|
|
1657
|
-
|
|
1289
|
+
No — and `clickwrap` never equates scrolling with reading. It makes documents available before action and records the exact presentation. A policy can require an observed open/review interaction if your app truly needs one.
|
|
1658
1290
|
|
|
1659
|
-
###
|
|
1291
|
+
### Should I record IP addresses?
|
|
1660
1292
|
|
|
1661
|
-
|
|
1293
|
+
Only for policies with a real, documented purpose. They corroborate request context; they don't prove identity or location. Everything defaults off.
|
|
1662
1294
|
|
|
1663
|
-
### Can
|
|
1295
|
+
### Can I keep my domain models?
|
|
1664
1296
|
|
|
1665
|
-
Yes
|
|
1297
|
+
Yes, and you should. `clickwrap` owns presentation, evidence, lifecycle, and receipts — not your payout, eligibility, or employment rules.
|
|
1666
1298
|
|
|
1667
|
-
###
|
|
1668
|
-
|
|
1669
|
-
Yes—and usually should. Clickwrap complements domain models; it does not replace your payout, certification, identity, employment, or eligibility rules.
|
|
1670
|
-
|
|
1671
|
-
### Can Clickwrap prove the user saw the page?
|
|
1672
|
-
|
|
1673
|
-
It can prove the server generated and accepted a bound presentation manifest and record accurately observed interactions. It cannot prove human attention, comprehension, exact pixels, or legal sufficiency from a database row.
|
|
1299
|
+
### Is this GDPR compliant?
|
|
1674
1300
|
|
|
1675
|
-
|
|
1301
|
+
No gem can answer that. `clickwrap` gives you privacy-aware mechanisms, truthful defaults, and an inventory of exactly what you configured. Lawful basis, necessity, and data-subject rights remain yours.
|
|
1676
1302
|
|
|
1677
|
-
|
|
1303
|
+
## Compatibility
|
|
1678
1304
|
|
|
1679
|
-
|
|
1305
|
+
- Ruby 3.2+, Rails 7.1 through 8.x
|
|
1306
|
+
- PostgreSQL, SQLite, and MySQL for all portable core behavior (hardening is adapter-specific and labeled)
|
|
1307
|
+
- Integer and UUID primary keys; Devise and Rails authentication both optional
|
|
1308
|
+
- Runtime dependencies are only the Rails components the gem actually uses (`activerecord`, `actionpack`, `actionview`, `activesupport`, `railties`) — never Redis, a job backend, a JS runtime, or an external service
|
|
1680
1309
|
|
|
1681
|
-
|
|
1310
|
+
Persisted evidence gets a stricter promise than semver: every released receipt schema has a permanent golden fixture, new versions keep verifying old receipts, and released migrations are never edited underneath your app — see [Stability and upgrade promise](#stability-and-upgrade-promise).
|
|
1682
1311
|
|
|
1683
|
-
|
|
1312
|
+
## Stability and upgrade promise
|
|
1684
1313
|
|
|
1685
|
-
|
|
1314
|
+
`clickwrap` follows semantic versioning for its Ruby APIs. Evidence formats are stricter: a format change gets a new explicit schema and verifier, never a silent reinterpretation; upgrade generators add migrations and report their effects; and deprecations name their replacement and remain executable for a documented window.
|
|
1686
1315
|
|
|
1687
1316
|
## Development
|
|
1688
1317
|
|
|
1689
1318
|
```bash
|
|
1690
1319
|
bin/setup
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
bin/rails test
|
|
1320
|
+
bundle exec rake test
|
|
1321
|
+
bundle exec rubocop
|
|
1694
1322
|
```
|
|
1695
1323
|
|
|
1696
|
-
The project uses Minitest
|
|
1697
|
-
|
|
1698
|
-
Every change to canonicalization, schema, receipts, migrations, cryptographic fields, or lifecycle behavior must prove backward verification against all released fixtures.
|
|
1324
|
+
The project uses Minitest with a dummy Rails app, SimpleCov, RuboCop, Appraisal matrices, and SQLite/PostgreSQL/MySQL CI lanes. Fault-injection, concurrency, replay, stale-token, disposition, and golden-receipt tests are load-bearing, not extras.
|
|
1699
1325
|
|
|
1700
1326
|
## Contributing
|
|
1701
1327
|
|
|
1702
|
-
Bug reports and focused pull requests are welcome
|
|
1328
|
+
Bug reports and focused pull requests are welcome at https://github.com/rameerez/clickwrap. Please run `bundle exec rake test` and `bundle exec rubocop` first.
|
|
1703
1329
|
|
|
1704
|
-
|
|
1330
|
+
Two kinds of change need extra care: anything touching public vocabulary or an evidence claim (docs change alongside code, plus a note on receipts already written), and anything touching canonicalization, receipt schemas, digests, or migrations — released evidence formats are permanent. Security reports go through [`SECURITY.md`](SECURITY.md), privately.
|
|
1705
1331
|
|
|
1706
1332
|
## License
|
|
1707
1333
|
|
|
1708
|
-
MIT.
|
|
1334
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|