☑️ clickwrap — trustworthy agreements, consent, declarations, and authorizations for Rails

[!IMPORTANT] README-first product contract. clickwrap is not implemented or published yet. This README deliberately describes the finished gem we intend to build so we can work backward from the ideal developer experience. Every public promise below is an acceptance criterion, not a claim about code that exists today. Remove this notice only after the implementation and proof integrations satisfy it.

clickwrap is the missing evidence-and-assent layer for Rails.

It makes ordinary Terms acceptance and its action one beautiful form-builder call:

<%= form.clickwrap :signup, submit: "Create account" %>

And it grows with you all the way to expiring declarations, withdrawable consent, one-time authorizations, exact historical receipts, transaction-bound evidence, retention, legal holds, and independently verifiable exports—without making the simple path feel complicated.

receipt = 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)
end

If evidence cannot be recorded, the protected database action does not happen. If the action fails, the evidence does not pretend it succeeded.

No JavaScript package. No Redis. No external account. No legal-document vendor. No required per-event API call. No required background job. Just Rails, your database, and an API that reads like plain English.

[!TIP] Building a new Rails product? RailsFast ships the conventional signup integration, so new applications start with versioned Terms, a distinct Privacy Notice acknowledgment, atomic evidence, and receipts instead of inventing an accepted_terms_at column.

The five-minute version

Install it:

bundle add clickwrap
bin/rails generate clickwrap:install
bin/rails db:migrate

The installer detects Rails authentication versus Devise, integer versus UUID primary keys, and the database adapter. It generates adaptive migrations, one annotated initializer, a conventional signup policy, and the correct explicit authentication integration. It never invents legal text or silently guesses an ambiguous actor model.

Point the generated policy at the exact documents your application already owns:

# config/clickwrap.rb
Clickwrap.document :terms,
  version: "2026-08-15",
  from: Rails.root.join("app/content/legal/terms.md")

Clickwrap.document :privacy_notice,
  version: "2026-08-15",
  from: Rails.root.join("app/content/legal/privacy.md")

Clickwrap.policy :signup do
  agree_to :terms
  acknowledge :privacy_notice
end

Tell Clickwrap which records can act:

# app/models/user.rb
class User < ApplicationRecord
  has_clickwraps
end

Render the policy and its bound submit action:

<%= form_with model: resource do |form| %>
  <%# email, password, etc. %>

  <%= form.clickwrap :signup, submit: "Create account" %>
<% end %>

Publish immutable snapshots and boot the app:

bin/rails clickwrap:publish

That is the whole conventional integration. The helper renders the initially unselected controls and the submit button as one presentation, so the exact call to action in the signed manifest is the one the user can press. The generated Rails-authentication or Devise adapter saves the account and required evidence in one database transaction.

At first render there is no persisted user yet. Clickwrap does not pretend otherwise: it binds the presentation to a short-lived prospective-actor registration flow, then the authentication adapter binds the resulting account to that presentation inside the same transaction. The receipt identifies the attribution method as account registration, not an authenticated session.

From that moment on:

user.clickwraps.agreed_to?(:terms)                  # => true
user.clickwraps.acknowledged?(:privacy_notice)      # => true
user.clickwraps.current_for?(:signup)               # => true

receipt = user.clickwraps.receipts.last
receipt.event_id                                    # => "01K2..."
receipt.verify.success?                             # => true
receipt.to_canonical_json
receipt.to_html

Clickwrap preserves the exact document bytes and digests, policy revision, assertion and link text, choices, submit-button text, locale, presentation manifest, actor, authentication context, server time, lifecycle, and resulting protected action. Optional request evidence stays off until you explicitly ask for it.

Everything below is depth, not setup tax.

If you came for one particular job:


Why this gem exists

A checkbox is easy. Answering these questions three years later is not:

  • Which exact version did this person agree to?
  • What did the page actually say beside the control and submit button?
  • Was the checkbox initially empty and required on the server?
  • Did the account, payout, declaration, or provider handoff succeed without its evidence?
  • Was this consent later withdrawn?
  • Had this declaration expired?
  • Did this authorization cover this exact transaction, or was it replayed for another one?
  • Can an auditor reproduce the document without checking out historical application code?
  • Can optional personal request evidence be deleted without rewriting the historical event?
  • Can the exported receipt still be verified after several gem and Rails upgrades?

Most applications eventually accumulate some combination of:

accepted_terms_at
terms_version
an audit log
a few hidden form fields
an after_create callback
some IP-address columns
several domain-specific "confirmed_at" timestamps

Each part looks reasonable alone. Together they produce partial writes, client-owned policy decisions, mutable history, confused consent semantics, and evidence that only the original engineer can explain.

clickwrap turns that recurring plumbing into one coherent Rails primitive:

immutable document
      +
server-owned policy
      +
exact presentation
      +
explicit actor action
      +
atomic protected outcome
      +
append-only lifecycle
      =
reproducible receipt

It is intentionally not a “one checkbox makes anything legal” gem. It provides excellent evidence mechanics. Your application and counsel still own the words, lawful basis, fairness, capacity, authority, jurisdiction, formalities, and retention decisions.

Six verbs, six honest meanings

Not every checkbox is “consent,” and not every timestamp is a “signature.” Clickwrap gives each act the lifecycle it actually needs:

Policy verb Evidence kind Meaning Typical lifecycle
agree_to agreement Assent to contractual terms agreed → superseded/new version
acknowledge acknowledgment Affirmative receipt or awareness of a notice/risk acknowledged → superseded/expired
consent_to consent Purpose-specific permission where consent is the host’s chosen basis granted → withdrawn/renewed/scope changed
declare declaration A factual statement made by the actor declared → corrected/superseded/expired
attest attestation An operational fact affirmed by an authorized actor attested → corrected/superseded
authorize authorization Narrow permission bound to a protected action authorized → consumed/revoked/expired

The DSL is intentionally verbal:

Clickwrap.policy :example do
  agree_to :terms
  acknowledge :privacy_notice
  consent_to :product_updates, optional: true
  declare :information_is_accurate
  attest :bank_transfer_was_accepted
  authorize :withdrawal, one_time: true, valid_for: 10.minutes
end

The policy compiler rejects incoherent combinations at boot. A one-time authorization cannot be indefinite. Consent needs a withdrawal path. A declaration can expire without pretending the original statement was false. Withdrawing future consent never rewrites a historical agreement.

This taxonomy is product design, not statutory vocabulary. The host chooses the correct kind with appropriate legal/product review.

One submitted policy produces one root evidence event and one receipt, even when the policy contains several acts. Each act keeps its own kind, statement, documents, answer, and lifecycle under that root event. That gives the protected domain action one stable event_id to reference without flattening “agreed to Terms” and “acknowledged the Privacy Notice” into the same meaning.

Documents are immutable, reproducible records

Define a logical document once and publish as many immutable versions and locales as needed:

Clickwrap.document :terms,
  version: "2026-08-15",
  locale: :en,
  effective_at: Time.utc(2026, 8, 15),
  from: Rails.root.join("app/content/legal/terms.en.md")

Clickwrap.document :terms,
  version: "2026-08-15",
  locale: :es,
  effective_at: Time.utc(2026, 8, 15),
  from: Rails.root.join("app/content/legal/terms.es.md")

Publish them during development or deployment:

bin/rails clickwrap:publish

Publishing:

  • reads the exact bytes;
  • records media type and locale;
  • calculates a versioned digest;
  • snapshots the exact rendered representation when a source format is transformed for display;
  • records the renderer and sanitizer identity/version used for that representation;
  • freezes a database snapshot;
  • compiles and freezes every policy revision that references it; and
  • refuses to reuse a version label for different bytes.

The task is idempotent. A changed document requires a new version. Export never fetches a mutable live URL and calls it historical evidence.

Preview the plan without writing:

bin/rails clickwrap:publish:plan

The default database store is deliberately boring and complete. Larger applications can switch document bodies to content-addressed Active Storage or object-lock storage while keeping the same digest and receipt contract:

config.store_document_contents_in = :active_storage

Every storage adapter must return immutable bytes plus a verifiable digest. A URL alone is never a document version.

Markdown, HTML, plain text, and attached files are evidence inputs, not trusted markup by accident. The reference renderer sanitizes display HTML. A custom renderer must return the exact rendered bytes it offered, and Clickwrap stores their digest alongside the original-source digest. That preserves the distinction between “this Markdown file existed” and “this rendered representation was offered.”

Policies are server-owned offers

A policy declares what the server will present and accept. The browser may answer; it may never choose the policy, document version, validity, subject, retention, or request-evidence fields.

Clickwrap.policy :driver_declaration do
  declare :non_professional_driver,
    document: :driver_declaration,
    statement: "I declare that I drive privately and not as a professional driver.",
    valid_for: 1.year,
    subject_fingerprint_with: ->(scheme) { scheme.evidence_fingerprint }

  retain_with :regulated_evidence
end

Policies compile at boot. Clickwrap fails loudly for:

  • missing documents or locales;
  • duplicate statement keys;
  • invalid lifecycle options;
  • a consent policy without a configured withdrawal path;
  • a one-time authorization without expiry/consumption behavior;
  • request evidence without a named present purpose and retention decision;
  • a subject-bound policy without a subject fingerprint; or
  • a changed compiled policy reusing the same revision.

Policy revisions are defined pleasantly in Ruby and persisted as frozen canonical snapshots. Historical receipts do not need current source code to explain what revision meant.

Every human-facing value can be a literal, an I18n key, or a locale map. Clickwrap resolves it before presentation, fails closed when a required translation is missing, and stores the resolved text and locale—not merely an I18n key whose meaning may change later.

Reacceptance is explicit

New document bytes do not silently reinterpret old evidence:

Clickwrap.policy :current_terms do
  agree_to :terms, require_current_version: true
end
Clickwrap.required?(:current_terms, actor: user)     # => true after a new version publishes
user.clickwraps.current_for?(:current_terms)         # => false

The application decides which change is material. Clickwrap enforces the rule it is given; it does not decide legal materiality.

Before activating a new required version, operators can preview its effect:

bin/rails clickwrap:reacceptance:plan POLICY=current_terms

The plan reports affected actor counts and configured remediation routes without emailing anyone, changing current state, or calling the change “material.” Scheduled versions become presentable only at their explicit effective_at; correcting a published mistake means publishing a new version or stopping future presentation with an append-only operator reason, never replacing historical bytes.

Presentation manifests stop render-to-submit substitution

form.clickwrap does more than render controls. It creates a short-lived presentation manifest bound to an actor or prospective-actor flow, subject, and tenant containing:

  • policy key and frozen revision;
  • document versions, locales, and digests;
  • exact statements, labels, link labels/targets, choices, required state, and CTA text;
  • actor, tenant, and subject bindings;
  • subject fingerprint;
  • template, application, and gem versions;
  • capture channel;
  • issue time, expiry, and one-use nonce; and
  • a canonical manifest digest.

The browser receives a signed presentation token. On submit, Clickwrap verifies it against current server policy and rejects stale, swapped, expired, cross-account, cross-tenant, or cross-subject tokens.

A deploy between GET and POST never causes the server to record a version the actor was not offered. The policy either honors that still-valid presentation or asks the user to review the new one.

The default signed-manifest path performs no database write on GET. A high-assurance flow can explicitly retain pre-submit presentation attempts:

Clickwrap.policy :regulated_authorization do
  persist_presentations_before_submission_for 30.days,
    because: "Investigate disputes about this regulated authorization"
  authorize :regulated_action, one_time: true, valid_for: 10.minutes
end

Persisted presentations carry their own purpose, access, abuse controls, and retention; an abandoned GET is labeled presented_by_server, never accepted or seen_by_human.

The receipt says exactly what this proves: the server generated and accepted a particular presentation manifest. It does not claim the person read the document, understood it, saw particular pixels, or received a legally sufficient interface in every jurisdiction.

The form helper

The strongest happy path is one line because the component owns both the controls and the action whose wording it records:

<%= form.clickwrap :signup, submit: "Create account" %>

Submit options remain ordinary Rails:

<%= form.clickwrap :signup,
  actor: current_user,
  subject: @organization,
  locale: I18n.locale,
  submit: {
    text: "Create organization",
    class: "button button--primary",
    data: { turbo_submits_with: "Creating…" }
  } %>

The helper renders:

  • real, initially unselected controls;
  • kind-appropriate first-person language;
  • obvious document links before the submit action;
  • stable label/control/error associations;
  • server errors and accessible error summaries;
  • the signed presentation token; and
  • no hidden IP address, browser user-agent, policy version, validity date, or other client-owned security decision.

HTML required is progressive enhancement. Server validation is always authoritative.

If your design system needs to render the action separately, use the deliberately explicit split API:

<%= form.clickwrap_fields :signup,
  submit_button_text: "Create account" %>

<%= form.submit "Create account" %>

The repeated text is intentional: it makes the evidence contract visible in code. Development and system-test assertions compare the declared text with the rendered submit control and reject a mismatch. The one-call API is preferred because it makes that class of drift impossible.

Use the ready-made standalone remediation screen

Any policy can be completed outside its original flow:

# config/routes.rb
mount Clickwrap::Engine => "/agreements"
clickwrap_capture_path(:driver_declaration)

The engine provides actor-owned capture, receipt, consent-withdrawal, and document-history surfaces using your parent controller, layout, locale, and authorization callbacks. This makes a required agreement or declaration resolvable in place instead of becoming a dead end.

Eject or fully own the UI

Copy the tested reference views:

bin/rails generate clickwrap:views

Your copies shadow the gem’s views. Tailwind, Bootstrap, ViewComponent, Phlex, custom design systems, and plain ERB are all welcome.

For a completely custom surface, ask the presenter for primitives rather than recreating hidden inputs:

presentation = Clickwrap.present(
  :signup,
  actor: current_user,
  subject: nil,
  locale: I18n.locale,
  submit_button_text: "Create account"
)
<%= hidden_field_tag "clickwrap_submission[presentation_token]", presentation.token %>

<% presentation.statements.each do |statement| %>
  <%# Render statement.control_name, label, document links, choices and errors. %>
<% end %>

The development linter compares the submitted manifest with the policy/presenter contract and warns about missing statements, preselected consent, absent links, controls placed after the CTA, or unregistered custom copy. It reports objective problems; it never prints “legally compliant.”

Capture evidence and the protected action together

For an existing actor in a normal Rails controller:

def create
  withdrawal = current_user.withdrawals.build(withdrawal_params)

  receipt = capture_clickwrap_and!(
    :withdrawal_authorization,
    actor: current_user,
    subject: withdrawal
  ) do |pending_receipt|
    withdrawal.submit!(authorized_by_clickwrap_event: pending_receipt.event_id)
  end

  redirect_to withdrawal
end

The controller helper reads only the generated clickwrap_submission envelope and the current http_request. It delegates to the same public service API:

receipt = 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)
end

Within one supported database transaction, Clickwrap:

  1. verifies actor, tenant, subject, presentation, policy, document digests, answers, expiry, and nonce;
  2. acquires the required idempotency/subject locks;
  3. appends the pending evidence event;
  4. yields its receipt to the protected domain action;
  5. records the resulting outcome and consumes one-time authorization where applicable;
  6. commits both together; and
  7. invokes optional notifications/analytics only after commit.

If the event write fails, the protected action rolls back. If the block raises, the event rolls back. Repeating an identical idempotency key returns the original result without running the block twice. A conflicting replay fails with a stable Clickwrap::ReplayRejected result.

The block receives a read-only Clickwrap::PendingReceipt. Its stable event_id can be stored by the domain row, but export/verification methods are unavailable until commit. capture_and! returns the finalized Clickwrap::Receipt; if the transaction rolls back, the pending object becomes invalid instead of masquerading as committed evidence.

Atomic commit does not give Clickwrap permission to guess what a host method meant. Without a configured outcome snapshot, the receipt says only that the named policy, bound subject, evidence event, and block committed together. record_protected_outcome_with can add an exact post-action reference/state/fingerprint; it runs and validates inside the transaction, and a failure rolls the whole operation back.

The transaction contract is documented precisely for ownership, nested transactions, savepoints, deadlock/serialization retries, idempotency, callbacks, and after-commit behavior. Automatic retries occur only when Clickwrap can prove the block is safe to retry; otherwise a stable retryable error returns control to the host. Clickwrap never promises atomicity across two independent systems.

Capture without a protected action

receipt = Clickwrap.capture!(
  :current_terms,
  actor: current_user,
  http_request: request,
  submission: clickwrap_submission
)

Devise and Rails authentication

The installer detects the authentication stack and generates an explicit adapter—not a hidden after_create callback.

For Devise, the generated controller reads:

class Users::RegistrationsController < Devise::RegistrationsController
  clickwraps_registration_with :signup
end

For Rails’ authentication generator, the generated registration command uses:

register_with_clickwrap :signup, user: @user do
  @user.save!
end

Both integrations ensure account activation and required evidence commit together. Emails, sign-in, redirects, and after-commit side effects occur only after the transaction has succeeded. A failed evidence write never leaves a normal public account silently active.

Signup is modeled honestly as a prospective-actor flow:

  1. the GET creates a short-lived, signed registration-flow identifier;
  2. the presentation token binds to that flow, the form object type, and any host-selected tenant—not to a fictional persisted or authenticated user;
  3. the adapter validates the submitted presentation before account activation;
  4. one transaction persists the account, binds its stable actor reference to the evidence, and commits both; and
  5. the receipt records account_registration attribution and the actual pre-registration authentication state.

Email addresses, passwords, and raw signup fields are not copied into the token. A token from another browser flow, tenant, form object, or already-created account is rejected. Applications that own a custom registration service use the same primitive directly:

receipt = Clickwrap.register!(
  :signup,
  prospective_actor: @user,
  http_request: request,
  submission: clickwrap_submission
) do
  @user.save!
end

register! returns the same receipt type as capture_and!; the authentication adapters are thin conveniences over it.

External providers use an outbox, not pretend-ACID

Stripe, identity services, timestamp providers, and remote signatures cannot share your database transaction. Use a pending authorization and idempotent outbox:

authorization = Clickwrap.authorize_external_action!(
  :identity_provider_handoff,
  actor: current_user,
  subject: verification,
  http_request: request,
  submission: clickwrap_submission
)

ProviderHandoffJob.perform_later(
  authorization_id: authorization.id,
  idempotency_key: authorization.idempotency_key
)
authorization.record_provider_success_and_consume!(provider_receipt)

That final method is one idempotent local transaction. Failures and ambiguous timeouts use record_provider_failure! and record_provider_outcome_unknown!; the reconciliation task can safely resolve them later. A provider timeout never becomes a fictional success or a second debit.

Ask readable questions everywhere

The actor proxy is the everyday API:

user.clickwraps.current_for?(:signup)
user.clickwraps.required_for?(:current_terms)
user.clickwraps.agreed_to?(:terms)
user.clickwraps.acknowledged?(:privacy_notice)
user.clickwraps.consented_to?(:product_updates)
user.clickwraps.declared?(:non_professional_driver, subject: scheme)
user.clickwraps.authorized?(:withdrawal, subject: withdrawal)

Every predicate has a structured form when “no” needs an explanation:

result = Clickwrap.verify(
  :withdrawal_authorization,
  actor: user,
  subject: withdrawal
)

result.success?              # => false
result.error                 # => :declaration_expired
result.message               # localized human explanation
result.event_id
result.details               # stable machine-readable facts, no surprise PII

Stable errors cover wrong actor/tenant/subject, stale policy, unseen document version, missing answer, expiry, withdrawal, predecessor/order, fingerprint mismatch, consumption, replay, and integrity failure.

The convention is consistent: predicates answer booleans, verify returns a result, and bang methods raise a typed error carrying that same result. Applications never need to parse an English error message to make an authorization decision.

Controller gates that always have remediation

class BillingController < ApplicationController
  requires_clickwrap :current_terms, only: :show
end

The gate redirects HTML/Hotwire users to the mounted policy capture screen and returns them to the original safe destination after completion. API clients receive a structured clickwrap_required response with a presentation endpoint.

A required gate must have a remediation route or an explicit host support fallback. Clickwrap refuses to compile a dead-end gate.

Security-sensitive services should still verify at the domain boundary:

Clickwrap.require!(
  :withdrawal_authorization,
  actor: user,
  subject: withdrawal
)

Controller gates improve flow; service verification protects the action.

Consent is purpose-specific, initially unselected, and separate from Terms or a Privacy Notice acknowledgment:

Clickwrap.document :marketing_notice,
  version: "2026-08-15",
  from: Rails.root.join("app/content/legal/marketing.md")

Clickwrap.policy :marketing_preferences do
  consent_to :product_updates,
    document: :marketing_notice,
    optional: true,
    withdrawal_path: "/settings/privacy"

  consent_to :partner_offers,
    document: :marketing_notice,
    optional: true,
    withdrawal_path: "/settings/privacy"

  retain_with :marketing_consent_evidence
end

Leaving an optional checkbox unselected creates no consent grant. The capture receipt can show that the option was offered and not granted, but it does not call silence an affirmative refusal. A policy that truly needs a recorded yes/no choice uses explicit unselected controls:

consent_to :research_contact,
  choices: { yes: :grant, no: :decline },
  require_an_explicit_choice: true,
  withdrawal_path: "/settings/privacy"
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 deletes or mutates the historical grant. The policy’s post-commit hook can stop future processing or enqueue host-owned deletion work without making the original transaction depend on an analytics/job backend.

config.after_event_is_committed = lambda do |event|
  Marketing::StopProcessingJob.perform_later(event.actor_id) if event.consent_was_withdrawn?
end

Clickwrap structurally requires an accessible withdrawal path. It does not decide whether consent is the correct lawful basis.

Expiring and corrected declarations

Clickwrap.policy :driver_declaration do
  declare :non_professional_driver,
    document: :driver_declaration,
    valid_for: 1.year,
    subject_fingerprint_with: ->(scheme) { scheme.evidence_fingerprint }
end
user.clickwraps.declared?(:non_professional_driver, subject: scheme)
user.clickwraps.declaration(:non_professional_driver, subject: scheme).expires_at

Renewal always starts a new validity period. Correction, supersession, and expiry append linked lifecycle events:

Clickwrap.correct_declaration!(
  :non_professional_driver,
  actor: user,
  subject: scheme,
  replaces: old_receipt,
  http_request: request,
  submission: clickwrap_submission
)

The host retains domain-specific eligibility and declaration models. Clickwrap owns presentation, evidence, lifecycle, receipts, and verification—not your business rules.

Narrow, one-time authorizations

Clickwrap.policy :withdrawal_authorization do
  acknowledge :withdrawal_requirements

  declare :ride_exclusivity,
    subject_fingerprint_with: ->(withdrawal) { withdrawal.covered_rides_fingerprint }

  authorize :withdrawal,
    one_time: true,
    valid_for: 10.minutes,
    requires: %i[withdrawal_requirements ride_exclusivity],
    record_protected_outcome_with: lambda { |withdrawal|
      {
        action: :submitted,
        reference: withdrawal.to_gid.to_s,
        fingerprint: withdrawal.evidence_fingerprint
      }
    }
end

capture_and! locks and consumes the authorization in the same transaction as the withdrawal. Another withdrawal, changed ride set, stale declaration, wrong ordering, or concurrent replay cannot reuse it.

This is the core difference between “the user once accepted something” and “this exact evidence authorized this exact operation.”

Operator attestations

Clickwrap.policy :manual_bank_transfer do
  attest :beneficiary_matches_verified_identity
  attest :bank_accepted_transfer
  authorize :record_transfer_as_sent, one_time: true
end

Attestations preserve which authorized operator asserted which operational fact, under which role and authentication context, while the host owns permissions and domain state.

External agreements and imported receipts

When Stripe, DocuSign, Ironclad, or another provider owns the presentation, do not pretend your application captured the click:

Clickwrap.import_external_receipt!(
  :connected_account_service_agreement,
  actor: user,
  provider_name: "stripe",
  provider_event_id: .id,
  provider_receipt: .service_agreement,
  verified_with: :stripe_api,
  verified_at: Time.current
)

The event is labeled external_receipt, preserves provider provenance and validation status, and can participate in host verification without becoming a fictional local presentation.

Receipts answer “show me exactly what happened”

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.to_pdf              # optional renderer; never the source of truth
receipt.verify

An abbreviated receipt looks like:

{
  "schema": "clickwrap.receipt.v1",
  "event_id": "01K2Y8T5QY0N4V6N1H4G4CQY8J",
  "policy": { "key": "signup", "revision": "sha256:..." },
  "actor": {
    "type": "User",
    "reference": "usr_...",
    "attribution": { "method": "account_registration", "authenticated": false }
  },
  "acts": [
    { "statement": "terms", "kind": "agreement", "action": "agreed" },
    {
      "statement": "privacy_notice",
      "kind": "acknowledgment",
      "action": "acknowledged"
    }
  ],
  "documents": [
    { "key": "terms", "version": "2026-08-15", "locale": "en", "sha256": "..." },
    {
      "key": "privacy_notice",
      "version": "2026-08-15",
      "locale": "en",
      "sha256": "..."
    }
  ],
  "presentation": {
    "manifest_sha256": "...",
    "submit_button_text": "Create account",
    "offered_at": "2026-08-15T12:34:56.123456Z"
  },
  "outcome": { "type": "User", "reference": "usr_...", "status": "created" },
  "request_evidence": {
    "ip_address": { "state": "not_configured" },
    "browser_user_agent": { "state": "not_configured" },
    "ip_geolocation": { "state": "not_configured" }
  },
  "integrity": { "digest_algorithm": "sha256", "verified": true }
}

The bundle can include exact document files, manifest, per-act lifecycle/predecessor graph, protected outcome, optional provider receipts, integrity/checkpoint verification, system explanation, and verifier version.

to_canonical_json returns the verifiable core receipt and omits raw sensitive request evidence by default. Raw IP address, browser user-agent, and IP-geolocation values live in a separately encrypted evidence annex with its own digest, authorization, retention, hold, and disposition state. That boundary lets the core event remain immutable when a permitted retention process later removes the annex.

Canonical receipts use versioned schemas and the JSON Canonicalization Scheme (RFC 8785), plus a published Clickwrap profile for UTC timestamps, decimals, identifiers, binary digests, absent values, and extension names. They never depend on Ruby object serialization, YAML, database column order, or the current policy source. Unknown schema versions fail honestly instead of being “best effort” reinterpreted.

View and download

With the engine mounted:

clickwrap_receipt_path(receipt)

Actors can view their own receipts. Operator access is always host-authorized:

config.authorize_receipt_access_with = lambda do |controller, receipt|
  controller.current_user == receipt.actor || controller.current_user.admin?
end

Foreign IDs return not found; existence is not leaked.

Export only the sensitive fields you intend

Clickwrap.export_receipt(
  receipt,
  requested_by: current_operator,
  because: "Investigate dispute 2026-184",
  include_ip_address: false,
  include_browser_user_agent: false,
  include_ip_geolocation: false
)

There is intentionally no vague include_sensitive_context: true switch. Unredacted operator access and export require host authorization plus a human-readable reason and append an access event. Actor self-service follows the host’s configured disclosure policy without revealing internal fraud/security fields by accident.

Verify inside or outside the application

Clickwrap::Receipt.verify(canonical_json, documents: document_files)
clickwrap verify receipt.json --documents ./receipt-documents

The standalone verifier does not need the host application’s source code. At the baseline tier it verifies schema, canonical bytes, digests, links, and bundled content consistency; it does not claim that a self-contained file could not have been fabricated by someone controlling every source. Independent anchors/provider signatures add the stronger origin/time evidence they actually supply. Golden fixtures ensure new releases continue verifying every historical receipt format.

Optional request evidence, private by default

Clickwrap always records its event ID, server time, capture channel, policy/application version, configured actor/authentication source, and HTTP request ID when available.

It records none of these personal/request-derived fields unless the initializer or policy names them:

  • raw IP address;
  • raw browser User-Agent;
  • IP-geolocation country, region, city, postal code, coordinates, timezone, continent, metro code, or accuracy radius;
  • browser/device fingerprints; or
  • actual GPS/device location.

Browser fingerprinting and GPS are never collected by the base gem. IP geolocation is provider-estimated network context—not identity, GPS, a street address, or proof that the person was physically there.

Those defaults are evidence design, not fear of useful data. IP addresses and linked online identifiers can be personal data (Breyer, C-582/14); keeping them on first-party infrastructure does not remove purpose, lawful-basis, transparency, minimization, protection-by-default, security, retention, or high-risk-assessment duties (GDPR Articles 5, 6, 13, 25, 32, and 35). Clickwrap therefore supports rich capture while requiring a present, named posture.

MaxMind expressly describes GeoIP as approximate and not capable of identifying a household, individual, or street address; Cloudflare describes its fields as location information for an IP address (MaxMind accuracy guidance; Cloudflare IP geolocation). Clickwrap preserves that uncertainty instead of polishing an estimate into a stronger claim.

Enable exactly what one policy needs

Clickwrap.policy :regulated_authorization do
  authorize :regulated_action, one_time: true, valid_for: 10.minutes

  review_request_evidence_configuration_on Date.new(2027, 8, 15)

  record_ip_address(
    encrypted: true,
    retain_until: :regulated_evidence_retention_ends,
    because: "Investigate account compromise and disputes about this action",
    legal_basis_reference: "LIA-SECURITY-2026-01"
  )

  record_browser_user_agent(
    encrypted: true,
    retain_until: :regulated_evidence_retention_ends,
    because: "Corroborate the client context used for this action",
    legal_basis_reference: "LIA-SECURITY-2026-01"
  )

  record_ip_geolocation(
    country: true,
    region: true,
    city: true,
    postal_code: false,
    latitude_and_longitude: true,
    timezone: true,
    continent: false,
    metro_code: false,
    accuracy_radius_in_kilometers: true,
    using: :trackdown,
    retain_until: :regulated_evidence_retention_ends,
    because: "Corroborate anomalous access and investigate action disputes",
    legal_basis_reference: "LIA-SECURITY-2026-01",
    data_protection_impact_assessment_reference: "DPIA-2026-04"
  )
end

Every enabled IP-geolocation result carries provider name/source, estimated state, resolution time, unavailable reason, and any database/accuracy provenance the resolver supplies. A policy cannot keep provider-derived coordinates while stripping the uncertainty needed to interpret them.

Receipts distinguish not_configured, unavailable, recorded, redacted_for_this_viewer, deleted_after_retention, and held. “Blank” is never allowed to blur “we chose not to collect it” into “collection failed.”

The browser cannot submit or replace server-observed values. Clickwrap conventionally reads request.remote_ip, and the host must configure/test trusted proxies correctly; Rails documents the forwarding, trusted-proxy, and spoof-check assumptions in ActionDispatch::RemoteIp.

Required request enrichment resolves before the evidence/domain transaction begins and is carried into it as verified input; it is never filled in later by analytics. A policy chooses explicitly whether an unavailable resolver blocks capture or produces an unavailable state. Network resolvers are supported, but local databases or already-verified edge metadata avoid holding a domain transaction open around a remote call.

Trackdown is the optional official resolver

bundle add trackdown
config.ip_geolocation_resolver =
  Clickwrap::IpGeolocation::TrackdownResolver.new

trackdown remains optional. Clickwrap stores only the fields authorized by the active server policy, never the entire result object. Provider presence is not source trust: Cloudflare-derived fields are marked host-verified only when the application explicitly verifies that requests came through its trusted Cloudflare path.

footprinted remains analytics, not authoritative evidence. A sanitized event ID/policy/kind may be emitted to analytics after commit; analytics failure can never undo or substitute for the Clickwrap event.

Easy installer recipes without a fake compliance switch

The installer can scaffold either starting point:

bin/rails generate clickwrap:install \
  --request-evidence-recipe=privacy-minimized
bin/rails generate clickwrap:install \
  --request-evidence-recipe=evidence-rich

The second recipe asks about every field, purpose, encryption choice, access/export policy, trusted-source posture, and retention rule. It then writes every individual setting into the initializer and disappears. There is no runtime gdpr_compliant_mode, maximum_evidence, track_everything, or legal_proof option.

Recipes are scaffolding, never compliance verdicts.

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
  delete_recorded_ip_geolocation_after 90.days
end

Event-based and “later of” rules are supported for regulated records:

Clickwrap.retention :regulated_evidence do
  retain_core_event_until :regulated_evidence_retention_ends
  retain_recorded_ip_address_until :security_evidence_retention_ends
  retain_recorded_browser_user_agent_until :security_evidence_retention_ends
  retain_recorded_ip_geolocation_until :security_evidence_retention_ends
end
config.calculate_retention_time_for :regulated_evidence_retention_ends do |event|
  [
    event.recorded_at_by_server + 5.years,
    event.subject_liquidated_at&.+(3.years)
  ].compact.max
end

Clickwrap does not decide those periods. It makes reviewed policies executable and auditable.

Preview every disposition before applying it:

bin/rails clickwrap:retention:plan
bin/rails clickwrap:retention:apply PLAN=01K2Y8T5QY0N4V6N1H4G4CQY8J

The plan is immutable, scoped, expiring, and rechecked at apply time. A newly placed hold, changed policy, changed eligibility, or stale plan stops disposition instead of deleting a broader set than the operator reviewed.

Destructive public methods name exactly what they remove:

Clickwrap.delete_recorded_ip_address!(receipt, because: "Retention period ended")
Clickwrap.delete_recorded_browser_user_agent!(receipt, because: "Retention period ended")
Clickwrap.delete_recorded_ip_geolocation!(receipt, because: "Retention period ended")

Deletion removes the selected encrypted annex value, appends a disposition event, and changes the current receipt projection to deleted; it does not rewrite the historical agreement/declaration/authorization. Verification thereafter proves the immutable core event and its disposition history while reporting that the raw annex value is no longer available. A retained digest is described as a retained linkable digest, never automatically called anonymous.

receipt.place_on_legal_hold!(
  because: "Pending dispute 2026-184",
  placed_by: current_operator,
  review_on: 6.months.from_now
)

receipt.release_legal_hold!(
  because: "Dispute resolved",
  released_by: current_operator
)

A hold pauses scheduled disposition, requires a reason/owner/review date, and is itself append-only evidence.

Deleting an actor account never silently cascades evidence. The installer uses restrictive/nullifying relationships plus a stable configured pseudonymous actor reference. Host retention policy decides what remains.

Privacy inventory and actor requests

Clickwrap can describe what the application configured without pretending that configuration is lawful:

bin/rails clickwrap:privacy:inventory
bin/rails clickwrap:privacy:export ACTOR=gid://my-app/User/123
bin/rails clickwrap:privacy:disposition:plan ACTOR=gid://my-app/User/123

The inventory lists every policy, personal/request-derived field, stated purpose, host-supplied legal-basis reference, provider/source, encryption state, access callback, retention rule, unresolved host event, and review date. The actor export uses the same authorization/redaction rules as receipts. The disposition command only creates a reviewable plan; it does not decide whether an erasure request overrides retention duties, legal claims, or a hold.

Programmatic equivalents return structured results for a host-owned privacy workflow:

Clickwrap::Privacy.inventory
Clickwrap::Privacy.export_for(actor, requested_by: current_operator)
Clickwrap::Privacy.plan_disposition_for(
  actor,
  requested_by: current_operator,
  because: "Verified erasure request DSAR-2026-41"
)

Correcting an actor’s current email/name or unlinking an account changes the host projection, not the historical snapshot. A host may append a correction/linkage event when needed; Clickwrap never silently edits what an old receipt recorded.

Progressive, honest integrity

Clickwrap starts useful with an ordinary Rails database and lets serious applications add assurance without changing the capture API.

Tier Capability Honest claim
Baseline Canonical receipts, immutable snapshots, versioned SHA-256 digests, append-only public API, independent verifier Detects accidental/ordinary mutation of the verified bytes
Database hardening Constraints and adapter-specific update/delete protections Rejects unsupported mutation paths within the documented database threat model
Chained history Per-tenant or per-aggregate event chains/checkpoints Makes rewriting history detectable when checkpoints remain trustworthy
Independent anchoring Heads stored/published outside the primary database Improves evidence against a privileged primary-database rewrite
Trusted timestamp/provider RFC 3161 or qualified trust-service receipt adapters Preserves exactly the assurance and validation status supplied by that provider

Enable optional hardening explicitly:

bin/rails generate clickwrap:hardening --database
bin/rails db:migrate
config.digest_canonical_receipts_with = :sha256
config.chain_event_history_with = :sha256
config.anchor_event_history_with = MyIndependentAnchor.new
config.timestamp_receipts_with = MyRfc3161TimestampProvider.new

A local hash is never called tamper-proof. Server-recorded time is never called trusted time. An IP address is never called identity. Provider receipts are never upgraded into guarantees the provider did not make.

Run verification continuously:

bin/rails clickwrap:verify
bin/rails clickwrap:verify EVENT_ID

Multi-tenancy, actors, subjects, and authority

The conventional actor is User, but nothing is hard-coded:

Clickwrap.configure do |config|
  config.actor_class_name = "Account"
  config.current_actor_method_name = :current_account

  config.find_current_tenant_with = lambda do |controller|
    controller.current_organization
  end
end

Actors, subjects, and tenants are separate:

Clickwrap.capture!(
  :logo_rights_declaration,
  actor: current_user,
  subject: @organization,
  tenant: current_organization,
  http_request: request,
  submission: clickwrap_submission
)

Actor snapshots include only configured fields. Clickwrap never serializes a whole user or domain object into evidence.

Authentication, actor, organization, and subject are not collapsed into one polymorphic ID. A signed-in employee acting for an organization can be represented explicitly:

Clickwrap.capture!(
  :organization_terms,
  actor: current_user,
  acting_for: current_organization,
  subject: contract,
  authentication_context: clickwrap_authentication_context,
  http_request: request,
  submission: clickwrap_submission
)

By default, the configured actor must match the authenticated principal. Delegation, guardianship, service-account action, and impersonation are rejected unless the policy and host authority adapter explicitly permit them. When permitted, the receipt preserves the authenticated principal, asserted actor, represented party, authority source, role, and verification time as separate facts; Clickwrap does not decide whether that authority is legally sufficient.

Anonymous actors

Use a host-owned stable opaque identifier—not an IP address:

actor = Clickwrap.anonymous_actor("checkout_#{signed_checkout_id}")

The host owns later account linking and identity/capacity decisions.

System-created records and explicit exemptions

Seeds, imports, administrators, invitations, and service accounts must never “accept” by omitting a browser parameter or by fabricating a human click:

Clickwrap.exempt!(
  :signup,
  actor: Clickwrap.system_actor("database_seed"),
  subject: user,
  because: "Generated demo account; no human signup occurred"
)

The event is an exemption, not an agreement. Policies can permit or reject it explicitly. Every exemption records who/what created it and why.

Exemptions never satisfy agreed_to?, consented_to?, or another human-action predicate unless a policy asks the separate exempted_from? question. There is no “missing checkbox means system account” inference.

Hotwire, Hotwire Native, APIs, and no-JavaScript flows

The default helper is server-rendered HTML and works with:

  • normal full-page requests;
  • Turbo Drive and Turbo Frames;
  • validation re-renders with no JavaScript;
  • Hotwire Native web screens;
  • custom native/API presentations; and
  • operator/admin surfaces.

No Stimulus controller is required for correctness. An optional tiny controller may improve disabled-submit affordances, but server validation and evidence capture work without it.

Hotwire Native

Use the web component whenever possible. Legal-document links can open in the appropriate modal/sheet/external-browser context chosen by the host native shell. The same presentation token and receipt contract applies.

Native path configuration remains host-owned. Mount/capture routes include both GET and form-action paths so validation stays in the intended navigation context.

JSON/API clients

Present a policy through the same server-owned presenter:

presentation = Clickwrap.present(
  :signup,
  actor: api_actor,
  locale: :es,
  capture_channel: :native_api,
  submit_button_text: "Crear cuenta"
)

render json: presentation

The client renders the declared statements and returns only the signed token plus answers:

Clickwrap.capture!(
  :signup,
  actor: api_actor,
  capture_channel: :native_api,
  submission: Clickwrap.submission_from(params),
  client_reported_context: permitted_client_context
)

submission_from reads only the signed presentation token and the answer keys/types declared by that manifest; unknown keys and malformed choices are rejected. Client-reported values remain explicitly labeled. They can never masquerade as server-observed IP address, server time, trusted identity, or provider-estimated IP geolocation.

Accessible defaults without a fake certification

The reference helper and views ship with tested:

  • explicit labels and programmatic names;
  • initially unselected controls;
  • visible keyboard focus;
  • high-contrast conventional links;
  • aria-invalid and aria-describedby error relationships;
  • error summary and focus behavior;
  • keyboard operation;
  • non-color-only meaning;
  • no-JavaScript validation;
  • locale-aware document selection; and
  • review/correction support for consequential submissions.

The whole host page still determines placement, clutter, contrast, action wording, accessibility, and notice quality. Clickwrap can lint known hazards; it cannot certify a host application as accessible or an agreement as enforceable.

Operations you can understand at 03:00

bin/rails clickwrap:doctor
bin/rails clickwrap:publish:plan
bin/rails clickwrap:publish
bin/rails clickwrap:reacceptance:plan POLICY=current_terms
bin/rails clickwrap:verify
bin/rails clickwrap:export EVENT_ID
bin/rails clickwrap:retention:plan
bin/rails clickwrap:retention:apply PLAN=PLAN_ID
bin/rails clickwrap:holds:review
bin/rails clickwrap:privacy:inventory
bin/rails clickwrap:reconcile_external_actions

clickwrap:doctor reports objective configuration and data facts:

✓ 6 policies compiled
✓ all referenced documents are published and digest-verified
✓ signup has an atomic Devise integration
✓ every required gate has a remediation route
✓ request-derived personal data is off by default
! withdrawal_authorization records IP geolocation city without a review date
! Cloudflare source trust is unverified
✓ no overdue disposition jobs
✓ all checked event digests verify

It never prints “compliant,” “court-proof,” or “audit guaranteed.”

Metrics and notifications use stable policy/kind/outcome names without raw personal data labels. Sensitive values never appear in ordinary logs, exceptions, inspect, notifications, or metrics.

Testing is a first-class API

Include the helpers in Minitest:

class ActiveSupport::TestCase
  include Clickwrap::TestHelpers
end

Create real, internally consistent test evidence without knowing table details:

receipt = capture_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_acknowledged :privacy_notice, actor: user
assert_clickwrap_receipt_verifies receipt

System-test helpers drive the actual UI:

complete_clickwrap :signup
click_button "Create account"

Fault injection proves required atomicity:

Clickwrap::Testing.fail_next_event_write do
  assert_raises(Clickwrap::EventWriteFailed) do
    
  end
end

assert_not User.exists?(email: "person@example.com")
assert_no_clickwrap_event :signup

Concurrency, duplicate-submit, stale-token, actor/subject swap, disposition, legal-hold, export round-trip, and legacy-import helpers ship with the gem. No tests make real provider network calls.

The generated initializer explains itself

The complete initializer is annotated in plain English. A representative configuration looks like:

# config/initializers/clickwrap.rb
Clickwrap.configure do |config|
  config.actor_class_name = "User"
  config.current_actor_method_name = :current_user
  config.parent_controller_class_name = "ApplicationController"

  config.find_current_tenant_with = lambda do |controller|
    controller.current_organization if controller.respond_to?(:current_organization)
  end

  config.authorize_receipt_access_with = lambda do |controller, receipt|
    controller.current_user == receipt.actor
  end

  config.authorize_unredacted_request_evidence_access_with =
    lambda do |controller, receipt, because|
      controller.current_user&.security_operator? && because.present?
  end

  config.identify_actor_with = ->(actor) { actor.to_gid.to_s }
  # Add only reviewed fields your receipts truly need; never serialize the model.
  config.snapshot_actor_with = ->(_actor) { {} }
  config.describe_authentication_with = lambda do |controller|
    { method: :authenticated_session, authenticated_at: controller.session[:authenticated_at] }
  end

  config.store_document_contents_in = :database
  config.digest_canonical_receipts_with = :sha256
  config.chain_event_history_with = nil
  config.anchor_event_history_with = nil
  config.timestamp_receipts_with = nil
  config.application_version = -> { ENV["RELEASE_SHA"] }

  # Safe defaults: no raw network/browser/geolocation data is stored.
  config.record_ip_address_by_default = false
  config.record_browser_user_agent_by_default = false
  config.record_ip_geolocation_country_by_default = false
  config.record_ip_geolocation_region_by_default = false
  config.record_ip_geolocation_city_by_default = false
  config.record_ip_geolocation_postal_code_by_default = false
  config.record_ip_geolocation_latitude_and_longitude_by_default = false
  config.record_ip_geolocation_timezone_by_default = false
  config.record_ip_geolocation_continent_by_default = false
  config.record_ip_geolocation_metro_code_by_default = false
  config.record_ip_geolocation_accuracy_radius_in_kilometers_by_default = false

  # If a default above becomes true, fill in the matching plain-English
  # reason and a retention rule below. The policy compiler rejects an
  # enabled default whose purpose or retention is blank.
  config.reason_for_recording_ip_addresses_by_default = nil
  config.reason_for_recording_browser_user_agents_by_default = nil
  config.reason_for_recording_ip_geolocation_by_default = nil
  config.legal_basis_reference_for_recording_ip_addresses_by_default = nil
  config.legal_basis_reference_for_recording_browser_user_agents_by_default = nil
  config.legal_basis_reference_for_recording_ip_geolocation_by_default = nil
  config.review_default_request_evidence_configuration_on = nil

  config.encrypt_recorded_ip_addresses = true
  config.encrypt_recorded_browser_user_agents = true
  config.encrypt_recorded_ip_geolocation = true

  # Nil means every policy that enables the field must supply its own rule.
  config.delete_recorded_ip_addresses_after = nil
  config.delete_recorded_browser_user_agents_after = nil
  config.delete_recorded_ip_geolocation_after = nil

  config.read_ip_address_from_http_request_with =
    ->(http_request) { http_request.remote_ip }

  config.read_browser_user_agent_from_http_request_with =
    ->(http_request) { http_request.user_agent }

  config.ip_geolocation_resolver = nil
  config.fail_capture_when_ip_geolocation_is_unavailable = false

  # Runs only after required evidence and domain state have committed.
  # Hook failures are reported but can never undo the committed action.
  config.after_event_is_committed = ->(event) { }
  config.report_after_commit_failure_with = ->(error, event) { Rails.error.report(error) }
end

Every public setting validates its value and reads like a sentence. Class names are resolved lazily for Rails autoloading. Security-critical ambiguity fails at boot instead of becoming a surprising runtime default. A policy-level request-evidence declaration overrides these application defaults, so a high-risk authorization can collect more context without making ordinary signup inherit it.

Generators

bin/rails generate clickwrap:install
bin/rails generate clickwrap:policy driver_declaration
bin/rails generate clickwrap:document terms
bin/rails generate clickwrap:views
bin/rails generate clickwrap:hardening --database
bin/rails generate clickwrap:upgrade

The installer:

  • detects integer/UUID keys and supported database features;
  • detects Rails authentication and Devise without making either a hard dependency;
  • stops and explains itself when actor/tenant mappings are ambiguous;
  • asks before wiring signup or mounting routes;
  • asks separately about every request-evidence field;
  • writes plain-English purposes and retention placeholders that must be reviewed;
  • never overwrites host files without normal Rails generator conflict handling; and
  • prints a post-install checklist for documents, semantics, privacy, retention, trusted proxies, full-page UI review, and tests.

Upgrade generators create new migrations. Released migrations are never silently edited underneath an application.

Migrate without inventing history

From FinePrint

Preview first:

bin/rails clickwrap:import:fine_print:plan

Then import:

bin/rails clickwrap:import:fine_print

FinePrint contract versions and signatures become explicit imported_legacy events. Fields FinePrint did not record—presentation manifest, IP address, CTA, protected action—remain unknown or not_collected; Clickwrap never synthesizes them.

From accepted_terms_at

Clickwrap.import_legacy!(
  :terms,
  actor: user,
  occurred_at: user.accepted_terms_at,
  known: {
    document_version: user.terms_version
  },
  unknown: %i[
    exact_document_bytes
    presentation
    assertion
    submit_button_text
    request_evidence
  ],
  because: "Imported from users.accepted_terms_at"
)

Imports are append-only, provenance-labeled, idempotent, dry-runnable, and report every unknown. Historical weakness remains visible instead of being laundered into modern certainty.

Extension seams, not dependency soup

The core has small adapter contracts for:

  • document storage;
  • actor/tenant resolution;
  • identity/authentication snapshots;
  • IP geolocation;
  • independent checkpoints/anchors;
  • RFC 3161 or trust-service timestamps;
  • external clickwrap/signature providers;
  • object-lock/WORM storage;
  • PDF rendering;
  • authorization;
  • error reporting;
  • notifications; and
  • post-commit analytics/auditing.

Every optional adapter has a no-op default and explicit capability reporting. Installing Clickwrap never pulls in Redis, Sidekiq, Devise, Trackdown, Active Storage, a PDF library, a cloud SDK, or an external service unless the application chooses that integration.

ActiveSupport notifications are available for instrumentation:

ActiveSupport::Notifications.subscribe("event_committed.clickwrap") do |event|
  # event payload contains stable IDs and categories, not raw request evidence
end

Required writes are never delegated to notifications. Hooks are for observers, not authorization.

What Clickwrap does, what your application owns, and what the receipt proves

Area Clickwrap provides Your application/counsel owns Receipt/evidence
Documents immutable versions, bytes/digests, locales, publication text, translation, fairness, legal approval, materiality exact stored version and digest
Presentation tested controls/helper, manifest, token, stale/replay checks whole-page placement/design, final CTA, accessibility review server-generated manifest and accepted answers
Actor configured reference and authentication snapshot identity proofing, capacity, authority, guardian/organization rules exactly which configured actor/context was recorded
Agreements version/current-state mechanics enforceability, governing law, substantive terms agreement event and historical version
Privacy notice acknowledgment mechanics transparency content and lawful basis for processing notice version and acknowledgment event
Consent purposes, grant/withdrawal/renewal lifecycle whether consent is the correct basis and whether it is freely given exact grant/withdrawal history
Declarations statement snapshot, expiry/correction/supersession truth, eligibility, domain validation what was declared, when, for which subject
Authorizations scope, fingerprint, freshness, one-time consumption domain permission and external-provider consequences exact evidence-to-outcome binding
Request evidence explicit capture, provenance, encryption/redaction/disposition necessity, lawful basis, disclosure, trusted proxy/source, period selected fields and honest source/state
Integrity canonical digests, verification, optional chains/adapters keys, infrastructure, access controls, backups, operational procedures verification result and bounded assurance tier
Retention executable rules, holds, dry-run disposition legally appropriate periods and case-specific holds retention/hold/disposition history

Clickwrap is engineering infrastructure, not legal advice or a compliance certificate.

What Clickwrap deliberately does not become

Clickwrap does not:

  • draft or approve your legal documents;
  • choose a GDPR lawful basis or special-category condition;
  • decide whether a document change is material;
  • guarantee enforceability, admissibility, accessibility, or audit acceptance;
  • verify identity, age, capacity, guardianship, or organizational authority;
  • provide KYC, sanctions screening, fraud scoring, or biometrics;
  • become a cookie CMP, tracker scanner, or script blocker;
  • become DocuSign, Ironclad, a notary, a qualified trust-service provider, or a contract lifecycle platform;
  • call a local hash tamper-proof;
  • call an IP address identity or IP geolocation physical location;
  • require forced scrolling or claim it proves reading;
  • require a sprawling admin/document-authoring suite; or
  • hide collection behind compliant: true or maximum_evidence: true.

Adapters let those systems contribute provider receipts without changing what Clickwrap itself claims.

FinePrint and Clickwrap solve different-sized problems

FinePrint is established Rails prior art for versioned contracts, signatures, gates, and views. Clickwrap should never market itself as the first Rails agreement gem.

FinePrint’s documented core and signature model at the audited commit answer:

Did user U sign version N of contract X?

Clickwrap is for applications that also need to answer:

Which exact content and presentation was offered?
Which explicit statements and choices were made?
Did the required evidence and protected outcome commit together?
What subject or transaction did it cover?
Was it withdrawn, corrected, superseded, expired, or consumed?
Can the complete receipt be reproduced and verified independently?
Can optional personal request evidence be disposed of honestly?

The goal is to be easier in the first five minutes and dramatically stronger after five years in production—not FinePrint with more columns.

Electronic form does not cure an invalid underlying transaction, missing capacity/authority, or a special formality. The US E-SIGN Act preserves electronic validity while retaining substantive requirements and exclusions (15 U.S.C. § 7001; 15 U.S.C. § 7003). Electronic form also does not make an unfair term fair (Directive 93/13/EEC). EU eIDAS distinguishes ordinary electronic evidence from qualified electronic signatures and their specific legal effect (Regulation (EU) No 910/2014, Article 25).

US appellate formation decisions evaluate conspicuous notice and unambiguous assent in the context of the whole interface; no checkbox color or placement is a universal safe harbor (Berman v. Freedom Financial Network; Tejon v. Zeus Networks; Toth v. Everly Well).

GDPR consent must be demonstrable, distinguishable, and withdrawable, but consent is only one possible lawful basis. A privacy-information acknowledgment is not blanket consent (GDPR Article 6; GDPR Article 7; AEPD FAQ 02.48). GDPR also requires purpose limitation, data minimization, storage limitation, transparency, and security; “collect everything forever” is not the evidence-maximizing default (Article 5; Article 13; Article 32).

These sources motivate Clickwrap’s design. They do not turn the gem into legal advice or a universal safe harbor.

Security model

Clickwrap treats these as hostile until verified:

  • policy/document/version/validity values submitted by the client;
  • stale or replayed presentation tokens;
  • swapped actor, tenant, subject, or transaction IDs;
  • forwarded IP and Cloudflare headers outside a verified proxy path;
  • client timestamps and client-reported identity/location;
  • duplicate/concurrent submits;
  • mutable document sources;
  • after-commit analytics and provider callbacks; and
  • imported evidence without provider provenance.

Security-sensitive values are server-owned, signed/bound, rechecked inside the transaction, and represented by stable failure results. Rails’ CSRF/session/authentication protections remain host responsibilities. Encryption keys, signing keys, and adapter credentials use Rails credentials or application-provided key providers and support rotation with versioned key identifiers.

Report vulnerabilities privately according to SECURITY.md. Do not open a public issue containing an exploit or real evidence/PII.

Compatibility

The ideal supported matrix is:

  • Ruby 3.2 through current Ruby, tested explicitly;
  • Rails 7.1 through current Rails 8.x;
  • PostgreSQL, SQLite, and MySQL for all documented portable core behavior;
  • adapter-specific hardening clearly marked and tested;
  • Rails authentication and Devise, both optional integrations;
  • Turbo/Hotwire and ordinary HTML;
  • integer and UUID primary keys;
  • multi-database applications when evidence and protected action share the documented transaction boundary; and
  • API-only applications for model/service/JSON receipt APIs, with HTML engine mounting optional.

The gem depends only on the Rails components its approved surface needs. It does not depend on the rails meta-gem, Redis, a job backend, a JavaScript runtime, an external provider, or a CSS framework.

The actual released gemspec and CI matrix—not this wishlist—are authoritative once implementation exists.

Stability and upgrade promise

Clickwrap follows semantic versioning for its documented Ruby/Rails APIs, but persisted evidence gets a stricter promise:

  • every released receipt schema, canonicalization profile, digest field, event action, and lifecycle meaning has a permanent golden fixture;
  • new gem versions continue verifying old receipts even when they stop creating that old schema;
  • a format change gets a new explicit schema/version and verifier, never a silent reinterpretation;
  • upgrade generators add migrations and report their exact effects; released migration files are never edited under an installed application;
  • destructive or lossy data transitions require a plan, explicit operator action, and rollback/export guidance;
  • deprecations name the replacement and remain executable for a documented window; and
  • security fixes distinguish a vulnerable capture path from a verifier/display-only issue so operators know what historical evidence, if any, needs review.

The project publishes the CI matrix, generator diffs, benchmark script, receipt golden fixtures, threat-model changes, and upgrade notes with every release. “It still boots” is not enough for a gem whose value is long-lived evidence.

Performance

The ordinary capture path is one bounded database transaction with no network call. Documents and compiled policies are cached by immutable digest. Request geolocation, timestamp providers, external anchors, PDFs, and analytics are optional and never hidden in the simple path.

There is no global event-history mutex. Sequence/chain scope is per tenant or aggregate, benchmarked under contention, and independently checkpointed where enabled. Bulk export streams records and verifies incrementally.

Performance claims are published only with reproducible benchmarks against supported databases.

FAQ

Is this an electronic-signature gem?

It captures electronic evidence of explicit actions and can import/provider-bind signature receipts. It does not call ordinary clickwrap a qualified electronic signature, notarization, or trusted identity proof.

Does a user have to open or scroll through the document?

Not by universal default. Clickwrap makes the document available before action and records the exact presentation. A policy can require an accurately observed open/review interaction when the host has a real requirement, but Clickwrap never equates scrolling with reading or understanding.

Should I record IP addresses and geolocation?

Only for policies with a present, documented purpose and reviewed access/retention posture. They can corroborate request context but do not repair weak notice or prove identity/physical location. All such fields default off.

Can I use Clickwrap without Devise?

Yes. Devise and Rails authentication are convenience adapters over the same public capture APIs.

Can one policy contain several documents and statements?

Yes. The receipt preserves each document/version, statement, choice, and ordering independently. Agreement, acknowledgment, and optional consent controls remain semantically separate even when one page presents them together.

Can I keep my domain-specific declaration or authorization model?

Yes—and usually should. Clickwrap complements domain models; it does not replace your payout, certification, identity, employment, or eligibility rules.

Can Clickwrap prove the user saw the page?

It can prove the server generated and accepted a bound presentation manifest and record accurately observed interactions. It cannot prove human attention, comprehension, exact pixels, or legal sufficiency from a database row.

What happens if Clickwrap is temporarily unavailable?

Required evidence fails closed: the same-database protected action rolls back. Optional after-commit hooks fail independently and are reported. Applications can define deliberate emergency/system exemptions with explicit actor and reason; there is no silent rescue-and-continue path.

Can I delete evidence?

Yes, according to explicit retention/disposition policy and legal holds. Optional request evidence is separately disposable. Core historical evidence is never silently deleted through an actor association, and disposition is itself recorded.

Is this GDPR compliant?

No gem can answer that universally. Clickwrap provides privacy-aware mechanisms and truthful defaults. The host remains responsible for lawful basis, necessity, transparency, data-subject rights, security, retention, processors/transfers, DPIAs, and jurisdiction-specific requirements.

Development

bin/setup
bin/test
bin/rubocop
bin/rails test

The project uses Minitest, a dummy Rails application, SimpleCov, RuboCop, Appraisal matrices, SQLite/PostgreSQL/MySQL integration lanes, concurrency/fault tests, generator tests, Brakeman where relevant, and independent receipt-verifier golden fixtures.

Every change to canonicalization, schema, receipts, migrations, cryptographic fields, or lifecycle behavior must prove backward verification against all released fixtures.

Contributing

Bug reports and focused pull requests are welcome once the repository opens for implementation. Changes to public vocabulary or evidence claims require corresponding documentation, source review, migration/compatibility analysis, and proof-integration coverage.

Please do not use issues to request jurisdiction-specific legal advice or ask maintainers to approve legal text.

License

MIT.