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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +612 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +241 -0
  7. data/app/controllers/clickwrap/application_controller.rb +79 -0
  8. data/app/controllers/clickwrap/captures_controller.rb +145 -0
  9. data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
  10. data/app/controllers/clickwrap/receipts_controller.rb +115 -0
  11. data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
  12. data/app/helpers/clickwrap/engine_helper.rb +97 -0
  13. data/app/views/clickwrap/captures/show.html.erb +34 -0
  14. data/app/views/clickwrap/receipts/index.html.erb +38 -0
  15. data/app/views/clickwrap/receipts/show.html.erb +91 -0
  16. data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
  17. data/app/views/clickwrap/shared/_fields.html.erb +100 -0
  18. data/app/views/clickwrap/shared/_statement.html.erb +105 -0
  19. data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
  20. data/config/locales/en.yml +160 -0
  21. data/config/locales/es.yml +138 -0
  22. data/config/routes.rb +41 -0
  23. data/exe/clickwrap +374 -0
  24. data/guides/README.md +30 -0
  25. data/guides/accessibility.md +249 -0
  26. data/guides/consent-and-lifecycle.md +299 -0
  27. data/guides/integrating.md +610 -0
  28. data/guides/integrity.md +212 -0
  29. data/guides/migrating.md +335 -0
  30. data/guides/naming.md +320 -0
  31. data/guides/organizations.md +320 -0
  32. data/guides/receipts-and-verification.md +415 -0
  33. data/guides/request-evidence.md +512 -0
  34. data/guides/retention-and-legal-holds.md +438 -0
  35. data/lib/clickwrap/actor_proxy.rb +147 -0
  36. data/lib/clickwrap/anonymous_actor.rb +47 -0
  37. data/lib/clickwrap/authority.rb +174 -0
  38. data/lib/clickwrap/canonical_json.rb +216 -0
  39. data/lib/clickwrap/capture/event_builder.rb +220 -0
  40. data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
  41. data/lib/clickwrap/capture.rb +650 -0
  42. data/lib/clickwrap/configuration.rb +1129 -0
  43. data/lib/clickwrap/controller_helpers.rb +758 -0
  44. data/lib/clickwrap/current_state.rb +282 -0
  45. data/lib/clickwrap/digest.rb +125 -0
  46. data/lib/clickwrap/doctor.rb +418 -0
  47. data/lib/clickwrap/document_definition.rb +255 -0
  48. data/lib/clickwrap/document_renderer.rb +83 -0
  49. data/lib/clickwrap/document_renderers/markdown.rb +175 -0
  50. data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
  51. data/lib/clickwrap/dsl/policy_builder.rb +462 -0
  52. data/lib/clickwrap/dsl/retention_builder.rb +89 -0
  53. data/lib/clickwrap/durable_commit_callback.rb +37 -0
  54. data/lib/clickwrap/engine.rb +184 -0
  55. data/lib/clickwrap/errors.rb +181 -0
  56. data/lib/clickwrap/form_builder_extensions.rb +341 -0
  57. data/lib/clickwrap/front_matter.rb +67 -0
  58. data/lib/clickwrap/identifier.rb +112 -0
  59. data/lib/clickwrap/import/external_receipt.rb +241 -0
  60. data/lib/clickwrap/import/fine_print.rb +290 -0
  61. data/lib/clickwrap/import/legacy.rb +450 -0
  62. data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
  63. data/lib/clickwrap/integrity/anchor.rb +130 -0
  64. data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
  65. data/lib/clickwrap/integrity/attestor.rb +221 -0
  66. data/lib/clickwrap/integrity/chain.rb +313 -0
  67. data/lib/clickwrap/integrity/timestamp.rb +143 -0
  68. data/lib/clickwrap/ip_geolocation/location.rb +112 -0
  69. data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
  70. data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
  71. data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
  72. data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
  73. data/lib/clickwrap/ip_geolocation.rb +16 -0
  74. data/lib/clickwrap/lifecycle.rb +534 -0
  75. data/lib/clickwrap/linter.rb +382 -0
  76. data/lib/clickwrap/localized_text.rb +101 -0
  77. data/lib/clickwrap/macros.rb +203 -0
  78. data/lib/clickwrap/models/application_record.rb +20 -0
  79. data/lib/clickwrap/models/chain_head.rb +79 -0
  80. data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
  81. data/lib/clickwrap/models/disposition_plan.rb +208 -0
  82. data/lib/clickwrap/models/document.rb +46 -0
  83. data/lib/clickwrap/models/document_version.rb +163 -0
  84. data/lib/clickwrap/models/event.rb +743 -0
  85. data/lib/clickwrap/models/event_document.rb +79 -0
  86. data/lib/clickwrap/models/event_statement.rb +92 -0
  87. data/lib/clickwrap/models/external_action.rb +150 -0
  88. data/lib/clickwrap/models/integrity_attestation.rb +90 -0
  89. data/lib/clickwrap/models/legal_hold.rb +81 -0
  90. data/lib/clickwrap/models/policy_revision.rb +115 -0
  91. data/lib/clickwrap/models/presentation.rb +59 -0
  92. data/lib/clickwrap/models/receipt_access.rb +53 -0
  93. data/lib/clickwrap/models/recording_sequence.rb +21 -0
  94. data/lib/clickwrap/models/request_evidence.rb +378 -0
  95. data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
  96. data/lib/clickwrap/models/statement_state.rb +130 -0
  97. data/lib/clickwrap/pending_receipt.rb +177 -0
  98. data/lib/clickwrap/policy.rb +283 -0
  99. data/lib/clickwrap/presentation_manifest.rb +210 -0
  100. data/lib/clickwrap/presenter.rb +716 -0
  101. data/lib/clickwrap/privacy.rb +419 -0
  102. data/lib/clickwrap/protected_outcome.rb +120 -0
  103. data/lib/clickwrap/receipt.rb +606 -0
  104. data/lib/clickwrap/receipt_html.rb +235 -0
  105. data/lib/clickwrap/receipt_verifier.rb +978 -0
  106. data/lib/clickwrap/reference.rb +44 -0
  107. data/lib/clickwrap/registration.rb +236 -0
  108. data/lib/clickwrap/registry.rb +54 -0
  109. data/lib/clickwrap/remediation_token.rb +155 -0
  110. data/lib/clickwrap/request_evidence_extractor.rb +590 -0
  111. data/lib/clickwrap/request_evidence_policy.rb +261 -0
  112. data/lib/clickwrap/retention/applier.rb +231 -0
  113. data/lib/clickwrap/retention/disposition.rb +221 -0
  114. data/lib/clickwrap/retention/planner.rb +502 -0
  115. data/lib/clickwrap/retention_class.rb +97 -0
  116. data/lib/clickwrap/reviewed_text.rb +28 -0
  117. data/lib/clickwrap/schema_requirements.rb +196 -0
  118. data/lib/clickwrap/services/authorize_external_action.rb +149 -0
  119. data/lib/clickwrap/services/load_policies.rb +69 -0
  120. data/lib/clickwrap/services/publish_documents.rb +251 -0
  121. data/lib/clickwrap/services/validate_policy_references.rb +166 -0
  122. data/lib/clickwrap/statement.rb +248 -0
  123. data/lib/clickwrap/subject_fingerprint.rb +28 -0
  124. data/lib/clickwrap/submission.rb +169 -0
  125. data/lib/clickwrap/system_actor.rb +31 -0
  126. data/lib/clickwrap/test_helpers.rb +676 -0
  127. data/lib/clickwrap/testing.rb +211 -0
  128. data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
  129. data/lib/clickwrap/verification.rb +504 -0
  130. data/lib/clickwrap/version.rb +12 -1
  131. data/lib/clickwrap/view_helpers.rb +190 -0
  132. data/lib/clickwrap/vocabulary.rb +294 -0
  133. data/lib/clickwrap.rb +497 -7
  134. data/lib/generators/clickwrap/document_generator.rb +164 -0
  135. data/lib/generators/clickwrap/hardening_generator.rb +177 -0
  136. data/lib/generators/clickwrap/install_generator.rb +1287 -0
  137. data/lib/generators/clickwrap/link_generator.rb +56 -0
  138. data/lib/generators/clickwrap/policy_generator.rb +118 -0
  139. data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
  140. data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
  141. data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
  142. data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
  143. data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
  144. data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
  145. data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
  146. data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
  147. data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
  148. data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
  149. data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
  150. data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
  151. data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
  152. data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
  153. data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
  154. data/lib/generators/clickwrap/views_generator.rb +101 -0
  155. data/lib/tasks/clickwrap.rake +569 -0
  156. metadata +311 -16
@@ -0,0 +1,610 @@
1
+ # Integrating clickwrap into a real application
2
+
3
+ The battle-tested playbook. Everything in this guide was learned by migrating
4
+ a production Rails app (Rails 8.1, PostGIS, UUID keys, Devise, Hotwire Native,
5
+ Spanish-first, a legacy acceptance ledger, and a wallet-debiting money path)
6
+ onto this gem, surface by surface, with the full test suite green after every
7
+ step. Follow it in order and you will not rediscover our mistakes.
8
+
9
+ It is written for humans and for AI agents alike: exact orders of operations,
10
+ the boot errors you will meet and what they mean, and the patterns that
11
+ survived contact with production code.
12
+
13
+ ## 0. The mental model in five lines
14
+
15
+ 1. **Documents** are frozen bytes with digests. Publishing is explicit.
16
+ 2. **Policies** are server-owned offers. The browser answers; it never chooses.
17
+ 3. **Presentations** are signed, short-lived, session-bound tokens. They
18
+ cannot be fabricated — not by attackers, and not by your tests.
19
+ 4. **Captures** commit evidence and your protected action in one transaction.
20
+ 5. **Verification** answers questions from the projection, live, with stable
21
+ error symbols and predicates.
22
+
23
+ ## 1. Install, in this exact order
24
+
25
+ ```ruby
26
+ # Gemfile — `bundle add clickwrap` would install 0.0.0, a deliberately empty
27
+ # name placeholder on RubyGems. Install from GitHub until the first real
28
+ # version is published there.
29
+ gem "clickwrap", github: "rameerez/clickwrap"
30
+ ```
31
+
32
+ ```bash
33
+ bundle install
34
+ bin/rails generate clickwrap:install # answers adapt to your app; say no to
35
+ # every request-evidence question first
36
+ bin/rails db:migrate
37
+ ```
38
+
39
+ Then, before anything else works:
40
+
41
+ 1. **Point the documents at your REAL legal content**, not generated
42
+ placeholders. If your legal pages already live at the Sitepress convention
43
+ (`app/content/pages/legal/terms.html.md` + `privacy.html.md`), the installer
44
+ detects them, points `from:` at those exact files, and writes no
45
+ placeholders — the text people accept and the text your `/legal` routes
46
+ serve must be one file, so they cannot drift. If your legal text lives
47
+ anywhere else, edit the two `from:` lines yourself. Then pick the renderer
48
+ that matches those pages: `config.document_renderer = :markdown` renders
49
+ through whichever Markdown library you already bundle (front matter
50
+ stripped), and `:markdown_rails` renders through your application's *own*
51
+ registered markdown-rails renderer — the exact pipeline those pages already
52
+ go through, which is what makes the accepted snapshot byte-for-byte the
53
+ rendered text those pages serve instead of merely similar to it.
54
+ 2. **Let each file name its own version.** A page with leading YAML front
55
+ matter carrying `clickwrap_version:` or `last_updated:` names its own
56
+ label, so there is no `version:` line in `config/clickwrap.rb` and no second
57
+ copy to drift:
58
+
59
+ ```markdown
60
+ ---
61
+ title: Terms of Service
62
+ last_updated: 2026-08-15
63
+ ---
64
+ ```
65
+
66
+ `clickwrap_version:` wins when both are present — that is how a same-day
67
+ correction gets a fresh label while the date readers see stays put.
68
+ Trailing `# comments` on the version line are read as YAML reads them:
69
+ not part of the label. If the app has a `TERMS_CURRENT_VERSION`
70
+ constant, move its exact value into the file's front matter and keep the
71
+ constant in lockstep until it retires.
72
+ Pass `version:` explicitly only for a source that cannot carry front matter
73
+ (a PDF, an HTML fragment, a `resolver:` whose bytes are read at publish
74
+ time). New text = new label; reusing a label for different bytes is refused
75
+ at publish, and a file with neither key and no `version:` fails the boot
76
+ with the fix in the sentence.
77
+
78
+ Your own pages usually need the same two answers from the same block, so
79
+ read it with the same reader instead of writing a third one:
80
+
81
+ ```ruby
82
+ Clickwrap::FrontMatter.version_label_in(File.read(path)) # "2026-08-15", or nil
83
+ Clickwrap::FrontMatter.strip(File.read(path)) # the body, block removed
84
+ ```
85
+
86
+ It takes a leading `---` block closed by `---` or `...`, reads simple
87
+ top-level `key: value` lines only, unquotes a quoted value, and drops an
88
+ unquoted trailing YAML comment — the two places every hand-rolled reader
89
+ eventually disagrees with this one, and then a page and its receipt name
90
+ different versions of the same file. `strip` affects the RENDERED
91
+ representation only; the source digest still covers the exact bytes, front
92
+ matter included, because that is what the file was.
93
+ 3. **Declare a retention class and use it.** `retain_with` is mandatory on
94
+ every policy, on purpose — mark the period `TODO(counsel)` if you must,
95
+ but pick one. If the legacy system kept evidence forever, any finite
96
+ period is a tightening; say so in the comment.
97
+ 4. `bin/rails clickwrap:publish`, then `bin/rails clickwrap:doctor`. The
98
+ doctor's output is your integration checklist from here on. That is the
99
+ only time you publish by hand: publishing rides `db:prepare`, so a deploy
100
+ that runs it also freezes the snapshots for what it declared, before the
101
+ server takes traffic. It is idempotent, silent when nothing is
102
+ declared, and a sentence rather than a crash when the tables are not
103
+ migrated yet — but a real refusal (a reused label over changed bytes)
104
+ fails the deploy out loud, which is what you want: the alternative is
105
+ signups failing quietly some hours later. Opt out with
106
+ `config.publish_documents_after_database_preparation = false`.
107
+
108
+ If a client needs special navigation attributes, keep the canonical partial
109
+ and configure the one narrow seam instead of ejecting it:
110
+
111
+ ```ruby
112
+ config.document_link_html_options_with = lambda do |_document|
113
+ { target: "_blank", rel: "noopener", data: { turbo: false } }
114
+ end
115
+ ```
116
+
117
+ This callback may choose how the client opens the immutable URL. It cannot
118
+ return `href:`: the exact href is rendered from, and signed into, the same
119
+ presentation manifest.
120
+
121
+ Shipping a Hotwire Native app? Declare the answer once instead, and get both
122
+ halves — the href and the attributes — from the same setting:
123
+
124
+ ```ruby
125
+ config.hotwire_native_document_links = {
126
+ open_in: :external_browser,
127
+ canonical_host: "https://www.example.com"
128
+ }
129
+ ```
130
+
131
+ We learned this on a native authentication sheet: a same-host document link is
132
+ routed by the app itself, which pops the sheet and takes the half-filled signup
133
+ form with it. `:external_browser` absolutizes the signed path against your
134
+ canonical host (which may be a callable, and must be `https`) and opens it
135
+ outside the WebView, so the form survives the round trip. `:same_screen` keeps
136
+ a plain same-host link for your own native path configuration to route. When
137
+ this is set it answers native renders entirely and the callback above keeps
138
+ answering everything else — so an app that needs different answers on different
139
+ screens sets nothing here and stays with the lambda.
140
+
141
+ Boot errors you may meet, all working as intended:
142
+
143
+ | Error says | It means |
144
+ |---|---|
145
+ | "presents document X but no declaration exists" | An `acknowledge`/`agree_to` defaults its document to its own key. Point `document:` at a real document — or say `document: nil` for an operational fact whose statement text is the whole notice. |
146
+ | "has no retention class" | Add `retain_with`. The gem will not default evidence to forever. |
147
+ | "no published version … is effective" | You declared but didn't publish, or the locale doesn't match. Run `clickwrap:publish`; check `locale:`. |
148
+ | "unknown option" anything | Options are allowlisted. The error names the valid set — a typo'd option can never silently disable a rule. |
149
+
150
+ ## 2. Test setup — do this before your first integration test
151
+
152
+ Presentations refuse unpublished documents in tests exactly as in
153
+ production, and signed tokens are session-bound so tests cannot mint them by
154
+ hand. Both facts produce the same two-part setup:
155
+
156
+ ```ruby
157
+ # test/test_helper.rb
158
+ class ActiveSupport::TestCase
159
+ include Clickwrap::TestHelpers
160
+ parallelize_setup { Clickwrap.publish! } # once per parallel worker...
161
+ end
162
+ Clickwrap.publish! # ...and once per process, for
163
+ # the runs Rails does not fork
164
+ ```
165
+
166
+ Model and service tests get evidence from `submit_clickwrap`, which presents
167
+ the policy through the real presenter, answers it, and captures — raising when
168
+ the capture is refused, because in a test a failed capture is a failed test:
169
+
170
+ ```ruby
171
+ receipt = submit_clickwrap(:signup, actor: user, answers: { terms: true, privacy_notice: true })
172
+ ```
173
+
174
+ It is deliberately a different verb from the controller's `capture_clickwrap`,
175
+ which captures a submission a person actually sent and absorbs refusals into
176
+ `false`. One name with two opposite answers to "what happens when this is
177
+ refused" is not a helper.
178
+
179
+ Integration tests then read the token off the rendered page, the way a
180
+ browser does:
181
+
182
+ ```ruby
183
+ post user_registration_path, params: {
184
+ user: { email: "person@example.com", password: "a-real-password" },
185
+ **clickwrap_params_from(new_user_registration_path) # GET the page, affirm all
186
+ }
187
+
188
+ # Decline one statement instead:
189
+ declined = clickwrap_params_from(some_path, answers: { terms: false })
190
+
191
+ # Radio choices use their exact rendered values. The first rendered choice is
192
+ # the default; pass the domain choice when your test depends on it:
193
+ contractor = clickwrap_params_from(
194
+ some_path,
195
+ answers: { employment_kind: "contractor" }
196
+ )
197
+ ```
198
+
199
+ Checkbox statements default to their affirmative value. Radio statements
200
+ default to the first choice rendered by the application. This keeps the helper
201
+ browser-faithful: it never substitutes the checkbox value `"1"` for an offered
202
+ choice such as `"employee"`. Pass an exact choice key when the choice matters.
203
+ For conventional `yes`/`no` radio groups, `false` selects `no`.
204
+
205
+ If a page renders several independent Clickwrap forms, select the exact form.
206
+ The helper refuses an ambiguous page rather than mixing one form's signed token
207
+ with another form's answers:
208
+
209
+ ```ruby
210
+ submission = clickwrap_submission_params_from(
211
+ response,
212
+ form_css_selector: "form[action='/withdrawals/confirm']"
213
+ )
214
+ ```
215
+
216
+ For service-level tests with no page in the loop, mint the submission
217
+ directly: `submission_for(present_clickwrap(:policy, actor:, ...), answers)`.
218
+
219
+ Two patterns from the trenches:
220
+
221
+ - **Multi-step funnels**: write a helper that GETs the funnel page, extracts
222
+ whatever presentation is on it, POSTs it to the matching gate, and repeats.
223
+ A funnel parked on a non-gate step simply walks zero gates — exactly like
224
+ the person it simulates. Dispatch on which answer keys the extracted
225
+ params contain.
226
+ - **Stubbing around captures**: mint the signed params BEFORE installing
227
+ stubs that count calls — the GET that renders a page may itself trigger
228
+ the code you are counting.
229
+
230
+ ## 3. Signup (Devise or Rails authentication)
231
+
232
+ ```ruby
233
+ class Users::RegistrationsController < Devise::RegistrationsController
234
+ clickwraps_registration_with :signup
235
+ end
236
+ ```
237
+
238
+ The adapter wraps exactly one thing — `resource.save` — so a heavily
239
+ customized `create` (bot checks, native handoffs, invitation prefills,
240
+ attribution) keeps working untouched. Account and evidence commit together;
241
+ refusals (stale token, missing box) re-render the form with localized
242
+ sentences, inline beside the control.
243
+
244
+ A hand-rolled door — Rails' authentication generator, an OAuth finish screen,
245
+ your own registration service — gets the same behavior from the non-bang
246
+ helper, which absorbs those same refusals into those same sentences and
247
+ returns false:
248
+
249
+ ```ruby
250
+ def create
251
+ @user = User.new(user_params)
252
+
253
+ unless register_with_clickwrap(:signup, user: @user) { @user.save! }
254
+ return render :new, status: :unprocessable_entity
255
+ end
256
+
257
+ start_new_session_for @user
258
+ redirect_to after_authentication_url
259
+ end
260
+ ```
261
+
262
+ `register_with_clickwrap!` is the raising half of the pair, for a flow that
263
+ handles the exceptions itself. Neither form absorbs an infrastructure failure:
264
+ a `Clickwrap::EventWriteFailed` escapes both, so the sign-in and the welcome
265
+ email that would follow never happen. Do not add a rescue that changes that.
266
+
267
+ **Migrating from a legacy checkbox?** Keep every required legacy evidence write
268
+ *inside* the new transaction during the transition:
269
+
270
+ ```ruby
271
+ clickwraps_registration_with :signup,
272
+ after_account_is_saved_inside_transaction: :record_legacy_acceptance!
273
+
274
+ private
275
+
276
+ def record_legacy_acceptance!(account:, pending_receipt:)
277
+ account.terms_acceptances.create!(
278
+ accepted_at: Time.current,
279
+ clickwrap_event_id: pending_receipt.event_id
280
+ )
281
+ end
282
+ ```
283
+
284
+ The callback runs after the account save but before the shared transaction can
285
+ commit. Do not rescue it: a required legacy-write failure must roll the account
286
+ and Clickwrap event back together. Assert the parity contract in one test:
287
+ Clickwrap predicates true AND legacy evidence stamped.
288
+
289
+ **Public forms with no authenticated account** — a lead magnet, newsletter, or
290
+ waitlist — must not find an existing actor by the visitor's typed email and bind
291
+ evidence to it. Knowing an address is not proof of controlling it. Use a
292
+ separate pending-request row, send a single-purpose confirmation link, and only
293
+ capture consent for the real actor after that link verifies mailbox control:
294
+
295
+ ```ruby
296
+ request = LeadSignupRequest.create!(email: params[:email])
297
+ LeadSignupMailer.confirm(request).deliver_later
298
+
299
+ # After the signed, expiring email link resolves the request:
300
+ lead = Lead.find_or_create_by!(email: request.email)
301
+ capture_clickwrap!(:marketing_preferences, actor: lead)
302
+ ```
303
+
304
+ The initial mail may deliver the requested transactional item. It must not
305
+ silently turn the form submit into marketing permission. Model the later box as
306
+ `consent_to ..., optional: true`: an unticked box records that the option was
307
+ offered and not taken, silence being neither refusal nor grant. Give it a real
308
+ `withdrawal_path:` where a signed email-footer token can call
309
+ `Clickwrap.withdraw!`; repeated withdrawal remains friendly and distinct from
310
+ "never granted".
311
+
312
+ ## 4. Custom surfaces — the three contracts
313
+
314
+ Anything that is not a plain `form.clickwrap` renders three things that fail
315
+ *silently* when hand-typed wrong. The helpers own them; you own every class
316
+ and wrapper:
317
+
318
+ ```erb
319
+ <% presentation = Clickwrap.present(:withdrawal_preparation, actor: current_user,
320
+ submit_button_text: "He leído todo: empezar") %>
321
+ <%= clickwrap_presentation_token_field(presentation) %>
322
+
323
+ <% if presentation.combined %>
324
+ <%= clickwrap_statement_check_box(presentation.combined, class: "your-checkbox") %>
325
+ <%= label_tag presentation.combined.control_id,
326
+ clickwrap_combined_sentence(presentation.combined) %>
327
+ <% end %>
328
+
329
+ <% presentation.itemized_statements.each do |statement| %>
330
+ <%= clickwrap_statement_check_box(statement, class: "your-checkbox") %>
331
+ <%= label_tag statement.control_id, statement.assertion %>
332
+ <% end %>
333
+
334
+ <%= clickwrap_submit_button(presentation, class: "your-button") %>
335
+ ```
336
+
337
+ Iterate `itemized_statements`, never `statements`: on a policy that composed, the
338
+ statements the line already covers have no control of their own, and rendering
339
+ one for each would offer a choice nobody has. (Present with `combined: false` if
340
+ you want a control per statement — then `combined` is nil and
341
+ `itemized_statements` is every statement, so the same template does both.)
342
+
343
+ `clickwrap_submit_button` is worded by the signed manifest itself — the CTA
344
+ is written once, at present time, so the recorded words and the pressed words
345
+ cannot drift.
346
+
347
+ ### Keeping `form.clickwrap_fields` and your own button
348
+
349
+ If you want the gem's controls but your design system's button markup, take the
350
+ block form rather than dropping to a fully custom surface. The block is yielded
351
+ the presentation, so the wording still comes from the signed manifest:
352
+
353
+ ```erb
354
+ <%= form.clickwrap_fields :signup, submit_button_text: "Create account" do |clickwrap| %>
355
+ <button type="submit" class="btn btn--primary" data-turbo-submits-with="Creating…">
356
+ <%= clickwrap.submit_button_text %>
357
+ </button>
358
+ <% end %>
359
+ ```
360
+
361
+ `submit:` on `form.clickwrap` and `submit_button_text:` on
362
+ `form.clickwrap_fields` are a deliberate pair, not a duplication:
363
+
364
+ | | What it means |
365
+ |---|---|
366
+ | `form.clickwrap :signup, submit: "Create account"` | Bind these words **and render the button**. |
367
+ | `form.clickwrap_fields :signup, submit_button_text: "Create account"` | Bind these words; **I render the action myself**. |
368
+
369
+ With `clickwrap_fields`, render the action one of three ways:
370
+ `form.clickwrap_submit` (Rails' button, wording reused automatically), the
371
+ block above (your markup, wording read off the presentation), or an ordinary
372
+ `form.submit "Create account"` — which Clickwrap checks against the signed
373
+ wording and refuses if the two disagree. A raw `<button>` outside the block
374
+ cannot be checked at all, which is exactly why the block exists.
375
+
376
+ Hard-won rules for custom surfaces:
377
+
378
+ - **Let the policy own the on-screen words.** Render `statement.assertion` as
379
+ the visible text (a radio card's description, a declaration's body). One
380
+ string on the screen and in the receipt beats two strings and a linter.
381
+ - **Radio-shaped answers**: options share the statement's control name;
382
+ affirmative submits `"1"`, negative submits `"0"`
383
+ (`clickwrap_statement_radio_button(statement, "1")` / `"0"`). Any other
384
+ non-empty value reads as affirmative — never use a semantic word like
385
+ `"professional"` as the negative value.
386
+ - **Nothing preselected, ever.** A preselected control records the page's
387
+ default, not the person's answer. Preserve selections on re-render only.
388
+ - **A dynamic CTA is good evidence**: "Retirar 57,50 €" in the manifest means
389
+ the button they pressed named the amount.
390
+ - Your consent-gate JavaScript (disable submit until every box is ticked) is
391
+ welcome as UX; the server refuses partial answers regardless.
392
+
393
+ ## 5. Refusals are one rescue
394
+
395
+ Everything a *person* can cause from a form — stale token, unparseable
396
+ submission, required box left empty — is one family with a message you can
397
+ put in front of them:
398
+
399
+ ```ruby
400
+ def create
401
+ # ... capture_clickwrap_and! wrapping the protected action ...
402
+ rescue Clickwrap::CaptureRefused => refusal
403
+ redirect_to somewhere_path, alert: refusal.user_facing_message, status: :see_other
404
+ end
405
+ ```
406
+
407
+ Drop the bang and you get the same thing without writing the rescue.
408
+ `capture_clickwrap` and `capture_clickwrap_and` absorb exactly that family,
409
+ return `false`, put the per-statement message beside the control, and leave the
410
+ refusal on `clickwrap_refusal`:
411
+
412
+ ```ruby
413
+ def create
414
+ receipt = capture_clickwrap_and(:withdrawal_authorization, subject: withdrawal) do
415
+ withdrawal.submit!
416
+ end
417
+
418
+ unless receipt
419
+ flash.now[:alert] = clickwrap_refusal.user_facing_message
420
+ return render :new, status: :unprocessable_entity
421
+ end
422
+
423
+ redirect_to withdrawal
424
+ end
425
+ ```
426
+
427
+ Everything outside the family (an evidence write failure above all) stays
428
+ loud on purpose, from the non-bang forms too. So do lifecycle conflicts:
429
+ `Clickwrap::ReplayRejected` and `Clickwrap::OneTimeAuthorizationConflict`
430
+ still raise, because "already done" needs a domain answer — usually "treat it
431
+ as done" — that a generic rescue cannot give. Never rescue `Clickwrap::Error`
432
+ wholesale: the fail-closed guarantee is that infrastructure problems refuse
433
+ the protected action.
434
+
435
+ ## 6. Link domain rows to their evidence
436
+
437
+ Any row whose existence a capture authorized — a withdrawal, a signed
438
+ declaration, a provisioned contract — gets the one-column link:
439
+
440
+ ```bash
441
+ bin/rails generate clickwrap:link payouts_withdrawals && bin/rails db:migrate
442
+ ```
443
+
444
+ ```ruby
445
+ class Payouts::Withdrawal < ApplicationRecord
446
+ has_clickwrap_evidence policy: :withdrawal_authorization,
447
+ statement: :withdrawal,
448
+ actor: :user,
449
+ subject: :self
450
+ end
451
+
452
+ capture_clickwrap_and!(:withdrawal_authorization) do |pending_receipt|
453
+ withdrawal.clickwrap_event_id = pending_receipt.event_id
454
+ withdrawal.save!
455
+ withdrawal
456
+ end
457
+
458
+ withdrawal.clickwrap_receipt.verify.success? # years later, one line
459
+ ```
460
+
461
+ ## 7. Protecting a money path (the full pattern)
462
+
463
+ The strongest shape we shipped, for anything where "the user once accepted
464
+ something" is not enough and you need "this exact evidence authorized this
465
+ exact operation":
466
+
467
+ - **One policy per gate**, each with its own `valid_for` freshness. A
468
+ multi-step funnel is multiple policies, not one policy squeezed into one
469
+ page.
470
+ - **Fingerprint the moving parts**: `subject_fingerprint_with:` recomputes
471
+ from committed rows at capture, so anything that changed between render
472
+ and submit refuses the submit instead of signing over a different state.
473
+ An exact retry of an already committed nonce is the deliberate exception:
474
+ Clickwrap verifies it against the frozen event and does not require the
475
+ protected action's pre-action fingerprint to survive its own success.
476
+ - **The final act is `authorize …, one_time: true`,** captured by
477
+ `capture_and!` INSIDE your own locked transaction, wrapping the debit or
478
+ transition itself. `capture_and!` joins an open transaction, so:
479
+
480
+ ```ruby
481
+ ActiveRecord::Base.transaction do
482
+ wallet = user.money_wallet.lock!
483
+ # your own rechecks under the lock...
484
+ Clickwrap.capture_and!(:withdrawal_authorization, actor: user, subject: user,
485
+ http_request: request, submission: submission,
486
+ authentication_context: { "method" => "password_reauthentication", ... }) do |pending_receipt|
487
+ withdrawal = debit_and_create_row!(pending_receipt.event_id)
488
+ withdrawal # exact result passed to `record_protected_outcome_with`
489
+ end
490
+ end
491
+ ```
492
+
493
+ Verification, one-time consumption (by unique index — a conflicting replay
494
+ cannot debit twice), the evidence event, and your debit commit together or
495
+ not at all.
496
+ - **Service-boundary checks read aloud**:
497
+
498
+ ```ruby
499
+ preparation = Clickwrap.verify(:withdrawal_preparation, actor: user,
500
+ require_current_revision: true)
501
+ declaration = Clickwrap.verify(:coverage_exclusivity, actor: user, subject: user,
502
+ require_current_revision: true)
503
+
504
+ declaration.subject_fingerprint_mismatch? # the order set changed
505
+ declaration.stale_policy_revision? # legal reworded it → re-ask
506
+ declaration.recorded_after?(preparation) # order enforced, not assumed
507
+ ```
508
+
509
+ `recorded_after?` reads a database-assigned recording sequence, so it holds
510
+ across actors, processes, and same-microsecond writes — ULID lexical order is
511
+ deliberately not chronology. Its `false` carries two meanings: "not after",
512
+ and "the order is not knowable", which is what evidence recorded before the
513
+ ordering migration and a missing event both answer. An upgrade cannot invent
514
+ honest order for rows written before it. Use it as a guard
515
+ (`return unless declaration.recorded_after?(preparation)`); never read a
516
+ `false` as proof that the reverse is true.
517
+
518
+ The same call takes an event id when the question is about one specific
519
+ recorded act rather than "does this actor currently satisfy the policy":
520
+
521
+ ```ruby
522
+ Clickwrap.verify(event_id, subject: order_batch, require_current_revision: true)
523
+ ```
524
+
525
+ Both keywords do the same work on that branch — the fingerprint is
526
+ re-derived from the record as it is now, and the act's recorded revision is
527
+ compared against the wording compiled today — so a host never reaches into
528
+ `Clickwrap::PolicyRevision` or `Clickwrap::SubjectFingerprint` to ask
529
+ whether stored evidence is still good. A policy that is no longer declared
530
+ answers `:unknown_policy` rather than passing.
531
+ - **Non-browser callers fail closed.** Pass `submission: nil` from a job or
532
+ console and capture refuses — which is correct: nothing can mint a
533
+ presentation but a real render. Give operators their own explicit rail.
534
+ - **Fresh-password proofs stay yours.** Validate them your way, then record
535
+ them by reference in `authentication_context:` — never the credential.
536
+
537
+ ## 8. Migrating history (do this once, early)
538
+
539
+ `Clickwrap.import_legacy!` exists so the gem answers for ALL acceptance
540
+ history, not just post-migration. The shape that worked:
541
+
542
+ - **Group legacy rows into acts** (by user + context + accepted-at instant):
543
+ one legacy act becomes one `imported_legacy` event, exactly as a live
544
+ capture would have.
545
+ - **`occurred_at` is the old record's time**; the import's own time stays
546
+ separate as `recorded_at_by_server`. The gap between them is itself
547
+ evidence.
548
+ - **Name what the source never recorded** in `unknown:` — bytes,
549
+ presentation, button text. Nothing is invented, and the receipt says so.
550
+ - **Do NOT copy raw IP/user-agent into `known:`** — that would move personal
551
+ data out of whatever protection it has into the un-encrypted core payload.
552
+ Record a pointer to where they are retained; migrate or delete them with
553
+ your request-evidence retention review.
554
+ - Imports are content-addressed idempotent: re-running imports nothing,
555
+ partial runs resume safely. Plan first (`dry_run: true`), always.
556
+ - Users with no evidence at all are **counted, never invented** — turning
557
+ them into reviewed `Clickwrap.exempt!` events is its own explicit step.
558
+ - Imported evidence satisfies `agreed_to?` / `current_for?` exactly as live
559
+ captures do — a migration must keep answering what the old system
560
+ answered, or it is a mass forced re-acceptance. The receipt stays honest
561
+ about the difference.
562
+
563
+ ## 9. Request evidence, when a surface earns it
564
+
565
+ Default to collecting nothing; enable per policy, per field, when a surface
566
+ has a real purpose (our money path did; our signup did not):
567
+
568
+ ```ruby
569
+ policy.record_ip_address(
570
+ encrypted: true,
571
+ delete_after: 2.years,
572
+ because: "Investigate disputes and account takeovers on withdrawals",
573
+ legal_basis_reference: "TODO(counsel): LIA payout evidence"
574
+ )
575
+ ```
576
+
577
+ Enabling any IP field requires `config.trusted_proxy_configuration_digest` —
578
+ a digest of the effective proxy rules, not a prose label, so old evidence
579
+ records which configuration was in force. Generate it from Rails' configured
580
+ rules (or Rails' actual defaults when none were overridden):
581
+
582
+ ```ruby
583
+ config.trusted_proxy_configuration_digest =
584
+ Clickwrap.trusted_proxy_configuration_digest_for_rails_application
585
+ ```
586
+
587
+ This records configuration provenance; it does not prove the rules were
588
+ correctly deployed or reviewed. Sharing the same fields across several policies?
589
+ A plain Ruby lambda in `config/clickwrap.rb` calling
590
+ `policy.record_ip_address(...)` is exactly right — each policy still names
591
+ its own enablement.
592
+
593
+ ## 10. The rollout doctrine
594
+
595
+ What let us migrate a live money path with zero downtime and zero weakening:
596
+
597
+ 1. **Dual-write inside the capture transaction.** Legacy evidence rows keep
598
+ being written — from inside `capture_and!`'s block, so both systems
599
+ commit together or neither.
600
+ 2. **Dual-belt at the boundary, strictest answer wins.** Keep every legacy
601
+ check verbatim and add clickwrap verification on top. Map clickwrap's
602
+ error symbols onto your existing error vocabulary so no UI copy changes.
603
+ 3. **Only then retire** legacy columns, with the import (§8) already done and
604
+ a parity test standing guard until the day you delete it.
605
+
606
+ Expect two intentional behavior *sharpenings* when gates become real: blocked
607
+ funnels can no longer be blind-POSTed into acknowledged state (no rendered
608
+ presentation = nothing to submit), and crafted POSTs outside the right step
609
+ bounce to the screen that explains why. Your tests may assert the old, looser
610
+ behavior; update them to assert the true one.