Tip
🚀 Ship your next Rails app 10x faster! I've built RailsFast, 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!
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.
✨ 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.
Ordinary Terms acceptance is one line in your signup form:
<%= form.clickwrap :signup, submit: "Create account" %>…and it renders one line on the page. One checkbox, one sentence, your legal pages linked inside it:
☐ I agree to the Terms of Service and I acknowledge the Privacy Policy.
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.
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:
Clickwrap.capture_and!(:withdrawal_authorization, actor: current_user, subject: withdrawal,
http_request: request, submission: clickwrap_submission) do |pending_receipt|
withdrawal.submit!(authorized_by_clickwrap_event: pending_receipt.event_id)
endIf the evidence can't be recorded, the action doesn't happen. If the action fails, the evidence doesn't pretend it succeeded.
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.
Important
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 applies from 0.1.0 onward.
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:
---
title: Terms of Service
last_updated: 2026-08-15
---
# Terms of Service# clickwrap-doc-test: syntax-only — terms.md and privacy.md are files in your app
# config/clickwrap.rb
Clickwrap.document :terms,
from: Rails.root.join("app/content/legal/terms.md"),
link: "/legal/terms"
Clickwrap.document :privacy_notice,
from: Rails.root.join("app/content/legal/privacy.md"),
link: "/legal/privacy"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.)
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:
Clickwrap.document :handbook,
version: "2026-08-15",
from: Rails.root.join("app/content/legal/handbook.pdf")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:
Clickwrap::FrontMatter.version_label_in(File.read(path)) # => "2026-08-15", or nil
Clickwrap::FrontMatter.strip(File.read(path)) # the body, without the blockIt 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.
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.
Then say how long the evidence lives and what the server offers:
Clickwrap.retention :ordinary_agreement_evidence do
retain_core_event_for 6.years
end
Clickwrap.policy :signup do
agree_to :terms
acknowledge :privacy_notice
retain_with :ordinary_agreement_evidence
end(Yes, the payload-retention decision is mandatory — clickwrap will not silently
default captured evidence or request evidence to "keep forever" and will not pick
a period for you. A minimal, digest-linked disposition tombstone remains after a
reviewed core deletion so the deletion itself does not become an unexplained hole.)
Add one macro to your model:
class User < ApplicationRecord
has_clickwraps
endRender the line and the submit button as one bound presentation:
<%= form.clickwrap :signup, submit: "Create account" %>☐ I agree to the Terms of Service and I acknowledge the Privacy Policy.
From that moment on, you can ask readable questions everywhere:
user.clickwraps.agreed_to?(:terms) # => true
user.clickwraps.acknowledged?(:privacy_notice) # => true
user.clickwraps.current_for?(:signup) # => trueAnd every acceptance produces a receipt you can export and verify — even outside your app, without your app's source code:
receipt = user.clickwraps.receipts.last
receipt.verify.success? # => true
receipt.to_canonical_json # canonical JSON for the standalone verifier
receipt.to_html # human-readable version of the same evidenceSounds good? Let's get started!
Add the gem and run the installer:
# Gemfile
gem "clickwrap", github: "rameerez/clickwrap"bundle install
bin/rails generate clickwrap:install
bin/rails db:migratebundle 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.
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:
bin/rails generate clickwrap:install --with-request-evidence # the IP / user-agent / geolocation annex
--with-integrity # event chaining, anchoring, timestamps
--with-retention-ops # legal holds and disposition plans
--with-external-actions # the outbox for external handoffs
--with-persisted-presentationsAdd 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.
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:
<%= form.clickwrap :signup, submit: "Create account" %>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:
# Devise — app/controllers/users/registrations_controller.rb
class Users::RegistrationsController < Devise::RegistrationsController
clickwraps_registration_with :signup
end# Rails authentication, an OAuth finish screen, a service object — any door
# that builds the record itself.
unless register_with_clickwrap(:signup, user: @user) { @user.save! }
return render :new, status: :unprocessable_entity
endDo 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.
Finally, publish immutable snapshots of your documents:
bin/rails clickwrap:publishThat's the only time you run that by hand: publishing rides db:prepare, so a deploy that runs
it also freezes the snapshots for whatever you declared, before the server takes traffic
(config.publish_documents_after_database_preparation = false if you'd rather own the step).
That's it! Your app now records which exact document versions the server offered, which explicit answers it accepted, the bound presentation wording, and when—atomically with account creation. Let's see how it works.
One line:
☐ I agree to the Terms of Service and I acknowledge the Privacy Policy.
One checkbox, one label, one sentence, with the documents linked inside it. The label is the
line, so pressing the words toggles the control and a screen reader announces the sentence and the
box together. There is no "Required" flag, no "(opens in a new tab)" printed beside every link, and
no version label under the checkbox. (The required attribute is still there as progressive
enhancement — the server decides — the "opens in a new tab" truth is still announced to screen
readers when the link really does open one, and versions still appear on receipts, where somebody
is actually reading the record.)
Behind that single control the evidence is unchanged: two statements, two kinds, two document versions, two lifecycles. Ticking the box records an agreement to the Terms and an acknowledgment of the Privacy Notice; leaving it empty refuses both. The manifest signs the exact composed sentence and which statements the one control answered, so the substitution defense holds over the wording a person actually read.
Clickwrap composes that line only when every statement in the policy is an ordinary, required,
default-worded agree_to or acknowledge. Anything else keeps a control of its own, below the
line:
Clickwrap.policy :signup do
# These two compose into the line.
agree_to :terms, link_label: "Terms of Service"
acknowledge :privacy_notice, link_label: "Privacy Policy"
# This one gets its own box, below the line, with its withdrawal route.
consent_to :product_updates,
document: :marketing_notice,
optional: true,
withdrawal_path: "/settings/privacy"
retain_with :ordinary_agreement_evidence
endAn optional consent is never folded in — bundling it would silently make it required, and unbundled
consent is the whole point of the consent_to verb. Neither is a recorded yes/no, a statement with
a withdrawal route, or copy your application wrote itself. And a policy with nothing composable —
the operator attestation rails, the payout authorization — renders exactly as it always has, one
control per act.
Want the itemized shape anyway? One boolean, and it reaches the presenter, so the manifest signs the shape that was actually offered:
<%= form.clickwrap :signup, submit: "Create account", combined: false %>The words are yours. clickwrap.sentence.agreement and clickwrap.sentence.acknowledgment are
ordinary translations with %{documents} marking where the links go, and each document's link text
comes from link_label: on the statement — which is how "Privacy Notice" becomes "Privacy Policy"
without touching what the statement asserts.
Legal pages in Markdown? config.document_renderer = :markdown renders through whichever
Markdown library you already bundle, and :markdown_rails renders through your application's
own registered markdown-rails renderer — the exact pipeline your public /legal pages go
through, so the snapshot people accept comes out byte-for-byte identical to the rendered text
those pages serve, by construction rather than by careful copying.
Wiring the gem into an existing production app — or handing the job to an AI agent? The integrating guide is the step-by-step playbook from a full production migration, in the exact order that avoids every mistake we made.
Hotwire Native? One setting answers both halves of the native question — the href and the link attributes:
Clickwrap.configure do |config|
config.hotwire_native_document_links = {
open_in: :external_browser,
canonical_host: "https://www.example.com"
}
endHere's why that matters: on a native authentication sheet, a same-host document
link is routed by the app itself, which pops the sheet and takes the half-filled
signup form with it. :external_browser absolutizes the signed document path
against your canonical host and opens it outside the WebView, so the form is
still there when the person comes back. :same_screen keeps a plain same-host
link for your own native path configuration to route (a document sheet inside a
signed-in funnel, say).
One app often needs both — the auth sheet must escape, the signed-in funnel
routes its own sheet — so open_in: also takes a callable:
config.hotwire_native_document_links = {
open_in: ->(controller) { controller.signing_up? ? :external_browser : :same_screen },
canonical_host: "https://www.example.com"
}It is asked once when the href is signed and once when the link is rendered, with the same controller both times, so the two halves of a link cannot disagree.
Another client needs different attributes, or different ones per screen? Keep the
gem's canonical partial and set config.document_link_html_options_with. It can
add data: { turbo: false }, target, or rel; it cannot replace the immutable
href that Clickwrap signs into the presentation. When the native setting above
is set it answers native renders entirely, and this hook goes on answering every
other render.
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.
clickwrap replaces that plumbing with one coherent primitive:
- 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.
- 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.
- Presentations are signed.
form.clickwrapcreates 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. - 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.
- 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.
- Receipts have a standalone verifier. Canonical JSON (RFC 8785) with versioned schemas and SHA-256 digests can be checked by the bundled
clickwrapCLI 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.
Not every checkbox is "consent," and not every timestamp is a "signature." Each verb gets the lifecycle it actually needs:
| Policy verb | Meaning | Typical lifecycle |
|---|---|---|
agree_to |
Assent to contractual terms | agreed → superseded by new version |
acknowledge |
Affirmative receipt of a notice or risk | acknowledged → superseded / expired |
consent_to |
Purpose-specific permission | granted → withdrawn / renewed |
declare |
A factual statement made by the actor | declared → corrected / expired |
attest |
An operational fact affirmed by an operator | attested → corrected / superseded |
authorize |
Narrow permission bound to one protected action | authorized → consumed / expired |
The DSL is intentionally verbal:
Clickwrap.policy :example do
agree_to :terms
acknowledge :privacy_notice
consent_to :product_updates, optional: true, withdrawal_path: "/settings/privacy"
declare :information_is_accurate
attest :bank_transfer_was_accepted
authorize :withdrawal, one_time: true, valid_for: 10.minutes
retain_with :ordinary_agreement_evidence
endThe 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.
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:
Declare the exact post-action snapshot once. The callback receives the value
returned by the protected-action block—not the pre-action subject—and
Clickwrap.protected_outcome owns the stable reference and canonical
fingerprint:
Clickwrap.policy :withdrawal_authorization do
authorize :withdrawal,
one_time: true,
valid_for: 10.minutes,
protected_outcome_version: "submitted-withdrawal-v1",
record_protected_outcome_with: lambda { |withdrawal|
Clickwrap.protected_outcome(
action: :submitted,
record: withdrawal,
state: withdrawal.status,
facts: {
amount_in_cents: withdrawal.amount_cents,
currency: withdrawal.currency,
destination_reference: withdrawal.destination_reference
}
)
}
retain_with :regulated_evidence
enddef create
withdrawal = current_user.withdrawals.build(withdrawal_params)
capture_clickwrap_and!(:withdrawal_authorization, actor: current_user, subject: withdrawal) do |pending_receipt|
withdrawal.submit!(authorized_by_clickwrap_event: pending_receipt.event_id)
withdrawal # the exact completed result given to the outcome recorder
end
redirect_to withdrawal
endIf 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.
Link the row to the evidence that authorized it, so the connection survives years and engineers:
bin/rails generate clickwrap:link withdrawals && bin/rails db:migrateclass Withdrawal < ApplicationRecord
has_clickwrap_evidence policy: :withdrawal_authorization,
statement: :withdrawal,
actor: :user,
subject: :self
end
capture_clickwrap_and!(:withdrawal_authorization, actor: current_user, subject: withdrawal) do |pending_receipt|
withdrawal.clickwrap_event_id = pending_receipt.event_id
withdrawal.save!
withdrawal
end
withdrawal.clickwrap_receipt.verify.success? # one line, years laterWhen the protected domain row needs the person's submitted choice, read it from the pending receipt rather than parsing controller params a second time:
capture_clickwrap_and!(:privacy_preferences, subject: membership) do |pending_receipt|
membership.show_on_public_profile =
pending_receipt.granted?(:public_profile_visibility)
membership.save!
endanswer_for, answered?, granted?, and declined? read the validated,
server-bound event being committed. An optional control left unselected returns
nil/false; a statement name the policy never declared raises. Silence can
therefore never become permission, while a typo cannot silently disable a
feature. This keeps the browser's raw params out of protected domain logic.
This model-first deployment order is safe. Before the generated column exists,
has_clickwrap_evidence stays inert and clickwrap_receipt returns nil; as
soon as the migration adds clickwrap_event_id, every new row is fail-closed
by default. That also lets historical data migrations replay schemas from
before Clickwrap without loading a model method for a column that did not yet
exist. It does not weaken current rows: after the column exists, missing,
mismatched, or replaced links fail validation.
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:
def create
# ... capture_clickwrap_and! as above ...
rescue Clickwrap::CaptureRefused => refusal
redirect_to new_withdrawal_path, alert: refusal.user_facing_message, status: :see_other
endOr drop the bang and let it read like save. capture_clickwrap_and and
capture_clickwrap absorb exactly that family, return false, put the
per-statement message beside the control it belongs to, and leave the whole
refusal on clickwrap_refusal:
def create
receipt = capture_clickwrap_and(:withdrawal_authorization, subject: withdrawal) do |pending_receipt|
withdrawal.submit!(authorized_by_clickwrap_event: pending_receipt.event_id)
end
unless receipt
flash.now[:alert] = clickwrap_refusal.user_facing_message
return render :new, status: :unprocessable_entity
end
redirect_to withdrawal
endNothing 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.
That atomicity has one exact boundary: Clickwrap's event and the protected domain write must use the same database connection. If a host model uses another Rails database/connection, its transaction cannot commit atomically with Clickwrap's tables. Put Clickwrap on the same connection for database-local work; use an explicit outbox/reconciliation design for another database or service.
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.
Controllers get the ambient actor, tenant, request, authentication context, and submitted presentation automatically:
authorization = authorize_clickwrap_external_action!(
:identity_provider_handoff,
subject: verification,
provider_name: "identity_provider"
)If a migration requires a legacy/domain projection to commit with the event and pending outbox row, use the deliberately named local-transaction callback (or the equivalent block form):
authorization = authorize_clickwrap_external_action!(
:identity_provider_handoff,
subject: verification,
provider_name: "identity_provider",
after_pending_action_is_saved_inside_transaction: lambda do |pending_action:, pending_receipt:|
LegacyAuditLog.create!(
event_id: pending_receipt.event_id,
external_action_id: pending_action.id
)
end
)It runs once, only on initial capture, inside that local database transaction; if it raises, all three local writes roll back. Never call the provider or do network work there. The provider call starts only after the helper returns.
Clickwrap.policy :withdrawal_authorization do
acknowledge :withdrawal_requirements
declare :coverage_exclusivity,
subject_fingerprint_version: "covered-orders-v1",
subject_fingerprint_with: ->(withdrawal) { withdrawal.covered_orders_fingerprint }
authorize :withdrawal,
one_time: true,
valid_for: 10.minutes,
requires: %i[withdrawal_requirements coverage_exclusivity]
retain_with :regulated_evidence
endThe 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."
The actor proxy is the everyday API:
user.clickwraps.current_for?(:signup)
user.clickwraps.agreed_to?(:terms)
user.clickwraps.consented_to?(:product_updates)
user.clickwraps.declared?(:independent_contractor, subject: scheme)
user.clickwraps.authorized?(:withdrawal, subject: withdrawal)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:
preparation = Clickwrap.verify(:withdrawal_preparation, actor: user,
require_current_revision: true)
declaration = Clickwrap.verify(:coverage_exclusivity, actor: user, subject: user,
require_current_revision: true)
declaration.stale_policy_revision? # legal reworded it → re-ask
declaration.subject_fingerprint_mismatch? # what it covers changed since capture
declaration.recorded_after?(preparation) # ordering enforced, not assumedrecorded_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.
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.
The same call takes an event id, which is how you re-ask about one specific recorded act years later:
Clickwrap.verify(event_id, subject: order_batch, require_current_revision: true)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".
Controller gates redirect users to a ready-made remediation screen and bring them back when they're done:
class BillingController < ApplicationController
requires_clickwrap :current_terms, only: :show
endClickwrap.policy :current_terms do
agree_to :terms, require_current_version: true
retain_with :ordinary_agreement_evidence
endPublish a new version and current_for? flips to false for everyone who accepted the old one. Preview the blast radius before you activate it:
bin/rails clickwrap:reacceptance:plan POLICY=current_termsConsent is purpose-specific, initially unselected, and separate from Terms:
Clickwrap.policy :marketing_preferences do
consent_to :product_updates, optional: true, withdrawal_path: "/settings/privacy"
consent_to :partner_offers, optional: true, withdrawal_path: "/settings/privacy"
retain_with :marketing_consent_evidence
endClickwrap.withdraw!(:product_updates, actor: current_user, http_request: request,
because: "The user withdrew this purpose in privacy settings")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.
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:
# The facts someone declared changed. A correction never implies the original
# was false when it was made.
Clickwrap.correct_declaration!(:contractor_status, actor: current_user, subject: engagement,
submission: clickwrap_submission,
because: "The person told us their circumstances changed")
# A new validity period, starting now — never the old expiry pushed along, so a
# stale expiry cannot quietly survive a renewal.
Clickwrap.renew!(:contractor_status, actor: current_user, subject: engagement,
submission: clickwrap_submission,
because: "The person renewed their declaration before it lapsed")
# Consent that now covers something narrower or wider. Rescoping is not
# withdrawal: the permission stays active, under new terms.
Clickwrap.change_consent_scope!(:product_updates, actor: current_user,
submission: clickwrap_submission,
because: "The person narrowed this permission in privacy settings")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.
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?.
Every event has one canonical JSON receipt and one human-readable HTML projection:
receipt = Clickwrap.receipt(event_id)
receipt.to_canonical_json
receipt.to_html
receipt.verifyAn abbreviated receipt:
{
"schema": "clickwrap.receipt.v1",
"event_id": "01K2Y8T5QY0N4V6N1H4G4CQY8J",
"policy": { "key": "signup", "revision": "sha256:..." },
"acts": [
{ "statement": "terms", "kind": "agreement", "action": "agreed" },
{ "statement": "privacy_notice", "kind": "acknowledgment", "action": "acknowledged" }
],
"documents": [
{
"key": "terms",
"version": "2026-08-15",
"locale": "en",
"source_digest": "sha256:...",
"rendered_digest": "sha256:..."
}
],
"presentation": {
"manifest_digest": "sha256:...",
"submit_button_text": "Create account",
"offered_at": "2026-08-15T12:34:56.123456Z"
},
"integrity": { "digest_algorithm": "sha256", "receipt_digest": "sha256:..." }
}Verify it inside the app, or completely outside it with the bundled CLI:
clickwrap verify receipt.json --documents ./receipt-documentsGolden fixtures make a verifier regression for any released receipt schema fail the test suite.
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 for exports, bundles, and what each verification tier does and doesn't establish.
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:
Clickwrap.policy :regulated_authorization do
authorize :regulated_action, one_time: true, valid_for: 10.minutes
record_ip_address(
encrypted: true,
retain_until: :regulated_evidence_retention_ends,
because: "Investigate account compromise and disputes about this action"
)
retain_with :regulated_evidence
endRecorded values live in a separately encrypted annex with their own retention, so
they can be deleted later without rewriting the core event payload. Core payloads
have their own reviewed disposition path and leave a digest-linked tombstone.
There is deliberately no gdpr_compliant_mode or maximum_evidence switch —
every field is named individually, in plain English.
For IP geolocation, trackdown 0.4 or newer is the optional official resolver:
Trackdown.configure do |trackdown|
trackdown.verify_request_came_through_trusted_cloudflare_path_with do |request|
request.env["my_app.cloudflare_origin_was_verified"] == true
end
end
config.ip_geolocation_resolver = Clickwrap::IpGeolocation::TrackdownResolver.newClickwrap passes the exact Rack request to Trackdown and records the provider that actually answered, its source and database provenance, and Trackdown's per-request trust result. It never treats CDN header presence as proof of a trusted path. The host must derive the Rack flag above from its real origin protection; Trackdown documents the supported patterns in “Did the request really come through your CDN?”.
The request evidence guide covers every field, the provenance model, and the privacy boundaries.
Every policy chooses an application-defined retention class:
Clickwrap.retention :ordinary_agreement_evidence do
retain_core_event_for 6.years
delete_recorded_ip_address_after 90.days
delete_recorded_browser_user_agent_after 90.days
endDisposition is previewed, planned, and applied explicitly — and rechecked at apply time, so a newly placed legal hold or changed policy stops a stale plan:
bin/rails clickwrap:retention:plan
bin/rails clickwrap:retention:apply PLAN=01K2Y8T5QY0N4V6N1H4G4CQY8JDestructive 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.
Start useful with an ordinary Rails database; add assurance without changing the capture API:
| Tier | What it adds |
|---|---|
| Baseline | Canonical receipts, immutable snapshots, SHA-256 digests, standalone verifier |
| Database hardening | Adapter-specific update/delete protections |
| Chained history | Per-tenant event chains and checkpoints |
| Independent anchoring | A verified publication of an exact event-chain snapshot outside the primary database |
| Third-party timestamps | A provider token over an exact event digest, with the adapter's verification result |
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 has the threat model.
bin/rails clickwrap:verify # verify continuously in productionThe 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.
# Devise
class Users::RegistrationsController < Devise::RegistrationsController
clickwraps_registration_with :signup
end# Rails authentication generator, or any hand-rolled signup door
def create
@user = User.new(user_params)
unless register_with_clickwrap(:signup, user: @user) { @user.save! }
return render :new, status: :unprocessable_entity
end
start_new_session_for @user
redirect_to after_authentication_url
endBoth 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.
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.
During a legacy migration, keep a required dual-write inside that same transaction without replacing Devise's controller action:
class Users::RegistrationsController < Devise::RegistrationsController
clickwraps_registration_with :signup,
after_account_is_saved_inside_transaction: :record_legacy_acceptance!
private
def record_legacy_acceptance!(account:, pending_receipt:)
account.terms_acceptances.create!(
clickwrap_event_id: pending_receipt.event_id,
accepted_at: Time.current
)
end
endIf that required legacy write fails, the account and Clickwrap evidence roll back with it. Remove the hook after parity and cutover are proved.
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:
<%= form.clickwrap_fields :signup, submit_button_text: "Create account" do |clickwrap| %>
<button type="submit" class="btn btn--primary"><%= clickwrap.submit_button_text %></button>
<% end %>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.
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 shows a full custom surface.
A human User can bind an Organizations::Organization without collapsing
the two identities:
Clickwrap.policy :organization_terms do
agree_to :organization_terms
permit_acting_for_organization when_actor_is_at_least: :admin
retain_with :ordinary_agreement_evidence
end<%= form.clickwrap :organization_terms,
acting_for: current_organization,
submit: "Accept for #{current_organization.name}" %>Authority is checked when the form is presented and reread from the membership
inside the capture transaction. The receipt records the human actor,
represented organization, actual role at both moments, authority criterion,
source, and verification times separately. Clickwrap records the configured
application-authorization fact; it does not decide whether that role is legally
sufficient. The same integration can create a brand-new organization and its
owner membership atomically through create_represented_party_with_clickwrap.
See Binding an organization through a human actor.
Two situations come up in almost every real app. Here's exactly how to handle both.
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.
Bump the document version when the new text ships — in the file itself, beside
the words that changed. The top of app/content/legal/terms.md:
---
title: Terms of Service
last_updated: 2026-11-01
---It was 2026-08-15; new words mean a new label. (A trailing # comment on
that line is read as YAML reads it — not part of the label.)
And require the current version in the policy:
# config/clickwrap.rb
Clickwrap.policy :current_terms do
agree_to :terms, require_current_version: true
retain_with :ordinary_agreement_evidence
endMount the built-in acceptance screen and wall the app:
# config/routes.rb
mount Clickwrap::Engine => "/agreements"class ApplicationController < ActionController::Base
# Nobody gets past this until they've accepted the current Terms. Clickwrap's
# own acceptance, receipt, withdrawal, and document screens stay reachable
# automatically, so this cannot redirect-loop its remediation page.
requires_clickwrap :current_terms
endPublish 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:
bin/rails clickwrap:reacceptance:plan POLICY=current_termsWhat 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.
Want to wall off only parts of the app instead? Gates are per-controller and per-action, and different areas can require different policies:
class BillingController < ApplicationController
requires_clickwrap :current_terms
end
class Api::DashboardController < ApplicationController
requires_clickwrap :developer_terms, only: %i[show update]
endAt 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).
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.
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:
# The OAuth callback doesn't create the account yet — it stashes what Google
# said and sends the person to finish signing up.
def google
session[:pending_oauth] = request.env["omniauth.auth"].slice("provider", "uid", "info")
redirect_to new_finish_signup_path
end<%# The finish screen: name and email prefilled from Google, plus your Terms. %>
<%= form_with model: @user, url: finish_signup_path do |form| %>
<%= form.clickwrap :signup, submit: "Create account" %>
<% end %>def create
@user = User.new(user_attributes_from(session[:pending_oauth]))
# Account + acceptance commit together, or neither happens. A refused
# submission re-renders the finish screen with the reason beside the control.
unless register_with_clickwrap(:signup, user: @user) { @user.save! }
return render :new, status: :unprocessable_entity
end
session.delete(:pending_oauth)
sign_in @user
redirect_to root_path
endThe 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.
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?
Declare in the policy who is allowed to accept for an organization — membership alone is deliberately not enough:
Clickwrap.policy :organization_terms do
agree_to :business_terms
permit_acting_for_organization when_actor_is_at_least: :admin
retain_with :ordinary_agreement_evidence
endMake the represented company conspicuous in the UI, and pass it as acting_for::
<p>You are accepting these terms for <strong><%= current_organization.name %></strong>.</p>
<%= form.clickwrap :organization_terms,
acting_for: current_organization,
submit: "Accept for #{current_organization.name}" %>Then capture the acceptance and stamp the organization in one transaction, so the rest of your app can ask a plain domain question:
def create
organization = current_organization
capture_clickwrap_and!(:organization_terms, acting_for: organization) do |pending_receipt|
organization.update!(terms_accepted_with_clickwrap_event_id: pending_receipt.event_id)
end
redirect_to organization_settings_path
endWhen the form is rendered, clickwrap verifies authority and signs that
presentation-time source, role, criterion, and verification time into the
manifest. At submit it requires a current membership in that exact
organization and rereads and locks the membership role inside the capture
transaction. An admin demoted between render and submit is refused; a still-
authorized role change is recorded honestly as two different snapshots. A
token rendered for one organization is rejected for another. The receipt keeps
the human actor, represented organization, both authority checks, and the
protected outcome as separate facts. An organizational acceptance never
quietly answers a personal one, and vice versa:
user.clickwraps.current_for?(:organization_terms, acting_for: organization) # => true
user.clickwraps.current_for?(:organization_terms) # => falseThat 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 gem, or with your own authority model via a registered adapter. The organizations guide has the full walkthrough.
If the organization does not exist until this same form creates it, opt into that materially different flow explicitly:
Clickwrap.policy :organization_creation do
declare :authority_and_content_rights,
statement: "I am authorized to create and act for this organization and may use the content I submit.",
document: nil,
protected_outcome_version: "created-organization-v1",
record_protected_outcome_with: ->(organization) {
Clickwrap.protected_outcome(
action: :created,
record: organization,
facts: { name: organization.name }
)
}
permit_acting_for_organization(
when_actor_is_at_least: :owner,
including_when_this_action_creates_the_organization: true
)
retain_with :ordinary_agreement_evidence
end<%= form_with model: @organization do |form| %>
<%= form.clickwrap :organization_creation,
acting_for: @organization,
submit: "Create organization" %>
<% end %>create_represented_party_with_clickwrap(
:organization_creation,
represented_party: @organization
) do |pending_receipt|
@organization.save!
@organization.add_member!(current_user, role: :owner)
@organization.update!(creation_clickwrap_event_id: pending_receipt.event_id)
@organization
endThe form helper creates a server-owned browser-flow binding automatically. The
manifest says authority is not_yet_verifiable because the membership does not
exist yet; after the protected block returns the persisted organization and
creates its owner membership, the adapter verifies them and Clickwrap rebinds the final GlobalID
before commit. If any part fails, none of the organization, membership,
evidence, or protected outcome commits. The explicit declaration is still what
records the human's claim of pre-existing real-world authority: an owner role
created by the transaction proves an application fact, not the truth or legal
sufficiency of that claim.
Documents must be published in the test database too — presentations refuse unpublished documents in tests exactly as in production:
# test/test_helper.rb
class ActiveSupport::TestCase
include Clickwrap::TestHelpers
parallelize_setup { Clickwrap.publish! } # once per parallel worker...
end
Clickwrap.publish! # ...and once per processreceipt = submit_clickwrap(:signup, actor: user, answers: { terms: true, privacy_notice: true })
assert_clickwrap_current :signup, actor: user
assert_clickwrap_agreed_to :terms, actor: user
assert_clickwrap_receipt_verifies receiptsubmit_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".
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:
post user_registration_path, params: {
user: { email: "person@example.com", password: "a-real-password" },
**clickwrap_params_from(new_user_registration_path) # GET the page, affirm everything
}
# Decline one statement instead:
declined = clickwrap_params_from(new_user_registration_path, answers: { terms: false })
# Choice statements submit their real rendered values. By default the helper
# selects the first offered radio choice; name a different choice explicitly:
contractor = clickwrap_params_from(
new_user_registration_path,
answers: { employment_kind: "contractor" }
)Checkbox statements default to their affirmative value. Radio statements
default to the first choice rendered by the application, so tests exercise a
value the server actually offered instead of a fabricated checkbox value.
Pass the exact choice key when the choice matters. For a conventional
yes/no radio group, false selects no; explicit choice keys remain the
clearest option for domain-specific choices.
If one page renders several independent Clickwrap forms, select the exact form; the helper refuses an ambiguous page instead of combining one form's token with another form's answers:
submission = clickwrap_submission_params_from(
response,
form_css_selector: "form[action='/withdrawals/confirm']"
)Fault injection proves the atomicity claim in your own suite:
Clickwrap::Testing.fail_next_event_write do
assert_raises(Clickwrap::EventWriteFailed) { perform_signup }
end
assert_not User.exists?(email: "person@example.com")The generated initializer is fully annotated and every setting reads like a sentence. The essentials:
# config/initializers/clickwrap.rb
Clickwrap.configure do |config|
config.actor_class_name = "User"
config.current_actor_method_name = :current_user
config.authorize_receipt_access_with = lambda do |controller, receipt|
controller.current_user == receipt.actor
end
# Safe defaults: no IP address, browser user-agent, or IP geolocation is stored.
# Enable fields per policy, each with a plain-English purpose and retention rule.
# Optional hooks run only after evidence and domain state have committed:
config.after_event_is_committed = ->(event) { }
endOnly 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.
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.
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.
config.lint_presentations = false # or true to run it in another environmentnil (the default) means "decide from the environment".
bin/rails clickwrap:doctor # objective health report, never prints "compliant"
bin/rails clickwrap:publish # freeze document snapshots (idempotent; also rides db:prepare)
bin/rails clickwrap:verify # verify event digests
bin/rails clickwrap:retention:plan # preview disposition
bin/rails clickwrap:privacy:inventory # every configured personal-data field, purpose, and rule
bin/rails clickwrap:import:fine_print # migrate from FinePrint without inventing historyMigrating 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.
Here's the honest version, in plain words, because you deserve better than marketing copy on this question.
Electronic form alone is not a reason to deny a contract legal effect under the US E-SIGN Act (15 U.S.C. § 7001), and the EU's eIDAS regulation says an electronic signature may not be denied legal effect or admissibility solely because it is electronic or not qualified (Regulation 910/2014, Article 25). That does not decide what happens around the control in a particular downstream application:
Courts read your whole page, not your checkbox. In Berman v. Freedom Financial Network (a 2022 Ninth Circuit decision, opinion), 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, Toth v. Everly Well). 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.
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). A strong record of acceptance does not change the underlying term. The gem records your words; it can't make them fair.
Who acted, and in which capacity. clickwrap records the actor and the authentication and
authority facts your application supplies; it does not establish identity, capacity, or legal
authority. For an organization, it keeps the human actor distinct from the represented party and
records the role or permission criterion your application checked—see the recipe.
Your jurisdiction and your document type. The US E-SIGN Act expressly excludes categories including wills, specified family-law matters, and specified notices (15 U.S.C. § 7003). In the EU, a qualified electronic signature has the equivalent legal effect of a handwritten signature; Article 25 separately says other electronic signatures may not be denied legal effect or admissibility solely because they are electronic or not qualified (eIDAS Article 25). This gem does not produce or claim a qualified electronic signature.
And GDPR consent is its own animal. Consent has to be demonstrable and withdrawable (GDPR Article 7) — clickwrap gives you both mechanics — but merely acknowledging a privacy notice is not consent (regulator guidance from Spain's AEPD, FAQ 02.48). That's why acknowledge and consent_to are different verbs here, with different lifecycles.
Notice what's left after all of that: evidence. Those opinions examine what interface the
application offered and what action it recorded—not whether checkboxes are valid in the abstract.
Which exact version of the terms did the server bind to the form? What did the presentation
manifest say beside the control? Was the control initially unselected? Which explicit submission
did the server accept? Was consent later withdrawn? Most apps genuinely cannot reconstruct those
application-side facts; clickwrap exists so you can, with a receipt verifiable without the
producing application's source code. It still does not prove that a person perceived or
understood the interface.
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.
This gem provides evidence mechanics — excellent ones — and nothing else. It does not:
- draft or approve legal documents, or decide whether a change is "material";
- claim compliance, enforceability, admissibility, or "court-proof" anything;
- verify identity or age, or decide whether a configured role or permission is legally sufficient to bind an organization (identity, KYC, and legal capacity belong elsewhere);
- become DocuSign, a notary, a cookie CMP, or a contract-lifecycle platform;
- call a local hash tamper-proof, server time trusted time, or an IP address a person;
- hide data collection behind a
compliant: trueswitch.
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.
It captures electronic evidence of explicit actions and can import provider signature receipts. It does not call a checkbox a qualified electronic signature.
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.
Only for policies with a real, documented purpose. They corroborate request context; they don't prove identity or location. Everything defaults off.
Yes, and you should. clickwrap owns presentation, evidence, lifecycle, and receipts — not your payout, eligibility, or employment rules.
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.
- Ruby 3.2+, Rails 7.1 through 8.x
- PostgreSQL, SQLite, and MySQL for all portable core behavior (hardening is adapter-specific and labeled)
- Integer and UUID primary keys; Devise and Rails authentication both optional
- 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
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.
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.
bin/setup
bundle exec rake test
bundle exec rubocopThe 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.
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.
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, privately.
The gem is available as open source under the terms of the MIT License.