Legal documents & consent
Every RailsFast app asks its users to agree to something — the Terms of Service at signup, a privacy notice, maybe a marketing opt-in or a set of API terms later. RailsFast ships the whole evidence layer for those "I agree" moments through the clickwrap gem, wired in and working out of the box:
- Signup records real evidence, at both doors. The email form and the Google finish screen render the same affirmative control — one checkbox, one sentence — and the evidence commits in the same database transaction that creates the account. If the evidence can't be written, the account doesn't exist. No account nobody can explain, no phantom acceptance for an account that failed to save.
- Every acceptance produces a verifiable receipt: which document versions were offered (by content digest), the exact wording next to the button, what was answered, and when — readable by the user at
/agreements, and verifiable later. - The server owns the offer. What's presented is signed server-side and rechecked on submit. A stripped form, a hand-crafted POST, or an unticked box are all refused with a human sentence, not a 500 — and never by client-side JavaScript, because there is none.
The gem provides evidence mechanics — it records what was offered and what was done. The legal text itself, what it needs to say, and how long evidence should be kept are decisions for you and your counsel. RailsFast just makes sure that whatever your users accepted, you can show exactly what it was.
What the signup control actually looks like
One line, one checkbox, with your legal pages linked inside the sentence:
☐ I agree to the Terms and I acknowledge the Privacy Policy.
The label is the line, so pressing the words toggles the box and a screen reader announces the sentence and the control together. There's no "Required" flag, no version label printed under the checkbox, and no "(opens in a new tab)" beside every link — the required attribute is still there as progressive enhancement (the server decides), the 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 it cannot honestly absorb — an optional consent, a recorded yes/no, a statement with a withdrawal route, copy you wrote yourself — keeps a control of its own below the line. Want the itemized shape regardless? One boolean, and it reaches the presenter rather than the template, 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, and each link's text comes from link_label: on the statement — which is how "Privacy Notice" becomes "Privacy Policy" in the sentence without changing what the statement asserts.
The template's own two signup forms call form.clickwrap_fields :signup, submit_button_text: … and then render RailsFast's shared submit-button partial, because the button markup is shared with every other Devise page. It's the same presentation and the same evidence — just the split API, for when the action is rendered somewhere form.clickwrap can't reach. The wording is declared once and Clickwrap refuses a render where the signed text and the button text disagree.
Your legal pages ARE the documents
There's exactly one copy of your legal text: the Sitepress pages in app/content/pages/legal/ that your /legal/terms and /legal/privacy routes serve. The clickwrap documents point at those same files, render them through the same ApplicationMarkdown renderer, and skip any re-sanitization — so the frozen snapshot people accept is byte-identical to the page they can read. A test pins that equality (test/initializers/clickwrap_document_parity_test.rb), so the two can never silently drift apart.
The whole declaration is two keys, and that's what ships in config/clickwrap.rb:
Clickwrap.document :terms,
from: Rails.root.join("app/content/pages/legal/terms.html.md"),
link: "/legal/terms"
Clickwrap.document :privacy_notice,
from: Rails.root.join("app/content/pages/legal/privacy.html.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's the path Clickwrap both renders in the sentence and signs into the offer, so the receipt can never cite a different target from the link somebody actually pressed. Leave link: off and the sentence points at the engine's own rendering of the exact published version instead; RailsFast points at /legal because byte parity makes the two the same text either way.
No version: anywhere: the label lives in each page's own front matter and the gem reads it directly, so there's nothing to keep in sync.
---
title: Terms of Service
last_updated: 2026-01-01
---
last_updated is the version label. If you ever need to re-publish on the same day the display date shouldn't change (a typo fix that still changes bytes), add a clickwrap_version: key — it outranks last_updated as the label while the visible date stays put.
In Hotwire Native, those document links are absolutized against your canonical host so they open in the system browser. Both clickwrap screens render inside the native auth sheet, where a same-host navigation pops the sheet and loses the half-filled form behind it. The absolutized URL is what gets signed into the offer, so the evidence records exactly the link that was presented.
Changing your legal text
The everyday flow is one file and one command:
- Edit
app/content/pages/legal/terms.html.md(orprivacy.html.md). - Bump
last_updated:in the same file's front matter. - Run
bin/rails clickwrap:publish— or just deploy: publishing ridesdb:prepareautomatically.
Publishing reads the bytes once, digests them, and freezes an immutable snapshot in the database. From then on the snapshot is the evidence; the file is just where it came from. New signups start recording the new version immediately.
Two guardrails you'll hit if you skip a step, on purpose:
- Changed text under an unchanged label is refused. New words mean a new version label; the gem won't quietly reinterpret what an old acceptance meant.
- Whether a change requires re-acceptance is your call — the gem enforces the rule you give it (next section), it doesn't decide materiality for you.
bin/rails clickwrap:doctor checks the whole setup — unpublished documents, policies without retention, personal-data settings — and tells you in plain sentences what's wrong and what to do.
Requiring re-acceptance when the Terms change
The Apple-developer pattern — new terms ship, and the app is walled until each user accepts them — is a few declared lines:
# config/clickwrap.rb
Clickwrap.policy :current_terms do
agree_to :terms, require_current_version: true
retain_with :ordinary_agreement_evidence
end
# app/controllers/application_controller.rb
requires_clickwrap :current_terms
Publish a new Terms version and every signed-in user is redirected to the built-in acceptance screen on their next request, then sent back to wherever they were going. Accepting the new version supersedes the old acceptance without rewriting it — you keep a receipt for every version each user ever agreed to. Gates are per-controller and per-action, so you can wall only billing, or only the API dashboard, behind different policies.
Preview the blast radius before you flip it on:
bin/rails clickwrap:reacceptance:plan POLICY=current_terms
Adding a new legal document and acceptance flow
Say your product grows an API and you want developers to accept API terms before minting keys. Three declarations and one view helper:
The document file carries its version in its own front matter — without it, the declaration refuses to boot (a versionless legal document is not a document anyone can be said to have accepted). Put it alongside your other legal pages so it gets a public URL people can read at their leisure:
---
title: API Terms
last_updated: 2026-08-17
---
# API Terms
...
# config/clickwrap.rb — the file's own front matter names the version,
# exactly like the Terms and Privacy pages
Clickwrap.document :api_terms,
from: Rails.root.join("app/content/pages/legal/api-terms.html.md"),
# Where a PERSON reads it. Give the document a public page and name it
# here, and the sentence links there instead of to the engine's rendering.
link: "/legal/api-terms"
Clickwrap.policy :api_access do
agree_to :api_terms, link_label: "API Terms"
retain_with :ordinary_agreement_evidence
end
<%# the "Enable API access" form %>
<%= form_with url: api_access_path do |form| %>
<%= form.clickwrap :api_access, submit: "Enable API access" %>
<% end %>
# the controller action — evidence and the protected change commit together.
# The non-bang helper absorbs a refused submission (an unticked box, a stale
# page) into ready-to-render errors and returns false; only an infrastructure
# failure raises. The bang form (capture_clickwrap_and!) raises
# Clickwrap::CaptureRefused instead — right for jobs and consoles, but in a
# controller an unticked checkbox would be a 500 unless you rescue it.
def create
captured = capture_clickwrap_and(:api_access) do
current_user.enable_api_access!
end
redirect_to api_dashboard_path and return if captured
render :new, status: :unprocessable_entity
end
form.clickwrap renders the whole offer — the sentence (composed into one line where the policy allows it, itemized where it doesn't), the document links, the signed presentation token, and the submit button — and the words on that button are recorded in the evidence, so what the receipt says the person pressed is what they actually pressed. Then bin/rails clickwrap:publish and you're live.
Every capture and registration helper comes in a save/save!-style pair, exactly like Active Record. The signup doors in the template use the non-bang forms.
Pick the verb that says what the act actually is — each records a different kind of fact with its own lifecycle:
| Verb | What it records |
|---|---|
agree_to |
Assent to contractual terms — stands until a new version supersedes it |
acknowledge |
Receipt of information (a privacy notice is received, not signed) |
consent_to |
Permission for a purpose — optional, unselected, withdrawable |
declare |
A statement of fact by the user ("I am over 18") |
attest |
A statement of fact with an assertion of authority behind it |
authorize |
A one-time go-ahead for one protected action |
RailsFast's signup uses the first two — Terms are agreed to, the Privacy Notice is acknowledged, because a notice is information you're entitled to receive, not a contract you sign, and treating an acknowledgment as blanket consent would let one checkbox stand in for permissions nobody asked for.
Consent that can be withdrawn
Anything you genuinely need permission for — marketing email is the classic — gets its own policy, its own unselected control, and its own withdrawal path. Never bundled into signup, never preselected:
Clickwrap.policy :marketing_preferences do
consent_to :product_updates, optional: true, withdrawal_path: "/settings"
retain_with :ordinary_agreement_evidence
end
Clickwrap.withdraw!(:product_updates, actor: current_user, http_request: request,
because: "The user withdrew this purpose in privacy settings")
Withdrawal appends an event; it never edits or deletes the historical grant — "granted in March, withdrawn in June" stays answerable in both directions. (A commented-out version of exactly this ships in config/clickwrap.rb, ready to uncomment.)
Receipts, and who can read them
The engine is mounted at /agreements: users read their own receipts and the immutable document versions there, and can withdraw consents they've granted. Access is per-actor — the check lives in config/initializers/clickwrap.rb (authorize_receipt_access_with), and a foreign receipt id returns not found rather than leaking that it exists. The document-version pages are public on purpose: the signed-out visitor on your signup page must be able to read what they're being asked to agree to.
Personal data: off by default, forever
The template records no request-derived personal data with its evidence — no IP address, no user agent, no geolocation. Those are off in the gem by default, and config/initializers/clickwrap.rb keeps them off: the file carries only the template's actual decisions as live code, with every setting left at a default shown commented beside it. A settings file where thirty non-decisions are dressed up as decisions is a file nobody reads twice; bin/rails clickwrap:doctor prints the full effective posture whenever you want it.
Each of those fields is a separate setting because each is a separate decision about what to keep about a person, and none switches on as a side effect of another. Enabling one is never one line either: the gem refuses an enabled field with no recorded purpose and no deletion rule, annexes are encrypted at rest unless you write down why not, and IP geolocation additionally needs a resolver. If you do turn any of it on, remember that request.remote_ip is only as trustworthy as your proxy configuration — verify and test your trusted-proxy setup behind a load balancer or CDN, or you're recording a header the client controls.
There is deliberately no full_evidence, legal_proof, or regulation-named mode switch anywhere — a flag can't make a legal determination for you, and an option that switches on a category of personal data as a side effect of something else is exactly the failure this setup exists to prevent. Receipts also keep "we chose not to collect it", "collection failed", "recorded", and "deleted" as four distinct states, so the first never blurs into the second.
Signup IP and geolocation are recorded by RailsFast — on the users row, by the signup-attribution concern that feeds the admin's fraud-triage surface. They're deliberately not duplicated into the evidence annex, because a second copy would mean a second retention clock for the same fact.
Retention is declared next to the policies (Clickwrap.retention in config/clickwrap.rb). The shipped periods are placeholders, not recommendations — replace them with periods you've actually reviewed, and preview what any change would delete with bin/rails clickwrap:retention:plan before anything is deleted.
Testing
The test helpers drive the real path — a real server-side presentation, a real signed token read back out of the rendered page, a real capture — so a green suite means the actual flow works, not that a fixture matches itself:
# integration test: sign up exactly like a browser does
post user_registration_path, params: {
user: { email: "[email protected]", password: "password123", password_confirmation: "password123" },
**clickwrap_params_from(new_user_registration_path)
}
user = User.find_by!(email: "[email protected]")
assert_clickwrap_agreed_to :terms, actor: user
assert_clickwrap_acknowledged :privacy_notice, actor: user
test/test_helper.rb already publishes the documents once per process and once per parallel worker — presentations refuse unpublished documents in tests exactly as in production, so there's nothing extra to set up. Decline a control with answers: { terms: false }; omit the params entirely to test the stripped-form refusal.
Deploying
There's nothing to remember: publishing rides db:prepare. Any deploy that prepares the database also freezes any newly declared document versions before the server takes traffic — idempotent, silent when nothing is new, and a real publish refusal (a reused label over changed bytes) fails the deploy loudly, which beats signups failing quietly later. Opt out with config.publish_documents_after_database_preparation = false in the initializer if you'd rather run bin/rails clickwrap:publish yourself.
References
- The
clickwrapgem — README, recipes, and the full configuration reference: https://github.com/rameerez/clickwrap - Social login — how the Google door collects the same evidence through its finish screen
- User authentication & login